Skip to content
Tracon

Release notes

Every Tracon package is cut from one version line, so a release below covers every NuGet package of the family and the npm client at once. Each package’s release-notes metadata links straight to the entry for the version you installed.

Read Versions and upgrades before you move between previews: it carries the pinning rules and the upgrade sequence this page’s entries assume.

Agents and workflows gain the controls a production host needs before the surface freezes. The new Tracon.AGUI package lets a front end that speaks the AG-UI protocol run a Tracon agent. An agent keeps files in a workspace for each session and remembers what its user said in earlier sessions, a tool can ask the user for input in the middle of a call (MCP elicitation), and a workflow waits for a tool approval that a person answers. A callable agent can be pinned to a version, and a version number is never given twice for one name. A spent call-tree budget or tenant cost quota now stops a tool call, a function node or an agent step before it starts; a workflow continuation keeps the budget of the runs before it; and a configuration value Tracon cannot read stops the host at startup. The release adds three migrations to each SQL provider and breaks source in several places: read Upgrading from 1.0.0-preview.5 first.

  • Migrations. PostgreSQL 0063–0065, SQL Server 0051–0053 and SQLite 0050–0052. With AutoApplyMigrations (the default) they run at startup. They add the expected_tool_calls column of an eval case, an index on the agent files of a tenant by path, and the agent_definition_version_counters table, which starts the counter of each agent at the version it holds today. No column is dropped. An agent that was deleted and created again before the upgrade keeps only the numbers it holds now (see the IAgentDefinitionStore entry, Changed).
  • Public types changed. Each entry below (Changed) says what to do:
    • AgentDefinition.CallableAgentNames and AgentDefinitionRequest.CallableAgentNames are CallableAgents, a list of CallableAgentReference; a body that still carries callableAgentNames is refused with 400.
    • ModelBinding.AllowConcurrentToolCalls is ModelBinding.ToolLoop.AllowConcurrentToolCalls; a body that still carries the old key is refused with 400.
    • EvalCase.ExpectedTools and EvalCaseDraft.ExpectedTools are ExpectedToolCalls; a body that still carries expectedTools is refused with 400.
    • IJobStore.LeaseAsync takes one JobLeaseRequest.
    • A custom IAgentDefinitionStore, IRunStore or IJobStore must pass the new cases of AgentDefinitionStoreContract, RunStoreContract and JobStoreContract.
    • AddSpeechSynthesizer(name, null) and AddSpeechTranscriber(name, null) no longer compile.
    • Tracon.Client: EvalCaseInput.SourceKind and EvalCasePromotionRequest.SourceKind are EvalCaseSource, VoiceSessionRecord.EndReason is VoiceSessionEndReason, and the empty SourceKind2 class is gone (see the nullable enum entry, Fixed).
  • Behavior changes most likely to reach an upgrade, each described in its entry below:
    • A configuration value Tracon cannot read stops startup, and so does a list or map key that holds one value. Start the application once: the keys it names were ignored until now.
    • The eight tracon.* metrics that carried tracon.tenant.id carry it only when Tracon:Observability:IncludeTenantTag is true.
    • A tool call, a workflow function node or an agent step does not start once the call-tree budget or the cost quota of the tenant is spent, and a workflow continuation starts from what the runs before it spent.
    • An IToolAuthorizationHandler that denies ToolEffect.External now also closes search_knowledge and search_conversation_memory.
    • /v1/chat/completions refuses a model setting the agent owns, tool-call history and unknown roles with 400.
    • With strict session ownership, an A2A message needs a caller identity to own the conversation.
    • agentReview and toolCallArgsMatch are built-in names; a criterion or check registered in code under either name is refused.
    • An IAgentDecorator with an Order above 0 no longer sees TraconRunOptions.
  • Pin every Tracon package to the same exact version. The pre-release Microsoft Agent Framework hosting packages stay pinned at 1.25.0-preview.261014.1, Microsoft.Agents.AI.Hosting.OpenAI at 1.25.0-alpha.261014.1 and A2A.AspNetCore at 1.0.0-preview3. The Model Context Protocol SDK moves to 2.3.0.
  • A callable agent can be pinned to a version of the callee: CallableAgentReference.Version. An entry of AgentDefinition.CallableAgents with a version runs that stored version in every call mode (background tasks, one tool for each agent), its child run row records that version, and, for an agent kept in the database, the description the model reads comes from it. An entry with no version runs the callee’s current version, as before. A caller that a workflow step or an approval resumption pins to a version of its own now runs the callee versions that version names: a parent pinned to version 1 no longer calls a child at version 3 unless version 1 says so. A new version of a pinned callee leaves the compiled caller as it is. A name converts to a reference without a version (CallableAgents = ["editor"]), so a list of names stays a list of names. A save, POST /api/agents/validate and the compile refuse a version below 1, a version the callee does not have, a pin on an agent defined in code and a name listed twice: the codes are callable_agent_version_invalid, callable_agent_version_not_found and callable_agent_duplicate, each with the path of the entry. A pin that stopped holding after the save fails the caller by name before its first model call. The agent editor of the console shows a Version of … to run select under each checked agent that has versions and keeps a pin it saved; the version compare shows a change of pin. The OpenAPI document and both generated clients carry the new schema.

  • WorkflowAgentStepOptions.AgentVersionParameter lets a run choose the version of an agent step in a graph built in code. Set it to the name of a run parameter declared in WorkflowRegistrationOptions.Parameters. The step reads the value each time it runs: empty or absent runs the agent’s current version, a whole number from 1 runs that version and the step’s run row records it. A value that is not a whole number from 1, a name the workflow does not declare, a version the agent does not have and an agent defined in code fail the step by name before its model call; the error for a value that is not a number never repeats it. It cannot be set together with AgentVersion (GetWorkflowAgentStep throws ArgumentException). A run that continues after an answer reads the value the run started with: a number keeps its version through the wait, an empty value still means the current version when each step runs.

  • openai.strictJsonSchema and azure-openai.strictJsonSchema ask OpenAI to enforce the response schema while the model generates. Set either to true in ModelBinding.ProviderSettings (OpenAIProviderNames.StrictJsonSchemaSetting, AzureOpenAIProviderNames.StrictJsonSchemaSetting; SupportedSettings is no longer empty). The request then carries strict: true on the response format of an agent whose ResponseFormat is a JSON schema. It reaches the response format only: the tools of the agent stay non-strict, and a fallback link, which receives no provider settings, is not strict. The setting is off by default. openai reads it on both API surfaces and on every UseOpenAICompatible() registration; a server that does not know strict mode can refuse the request.

  • POST /api/agents/validate returns the response schema each provider’s client sends. The report has a new field responseSchemas, one entry per binding and per fallback link (provider, model, strict, schema), for providers that implement the new IResponseSchemaSupport (OpenAI and Azure). Three warnings name what differs from the schema you wrote, each with JSON Pointers into it: response_schema_optional_required (an optional property that does not allow null is sent as required), response_schema_keyword_not_enforced (a bound such as maxLength or pattern is sent as description text) and response_schema_strict_unsupported (strict mode only: the parts OpenAI refuses with HTTP 400, which are oneOf, allOf, if, then, dependentRequired, dependentSchemas and an object without additionalProperties: false). The last one is reported for openai and openai-responses on api.openai.com, the only service measured, and it is a warning so that it never stops a definition from being saved. The OpenAPI document and both generated clients carry the new field and the three codes.

  • IResponseSchemaSupport and ResponseSchemaShape. An optional interface of an IModelProvider: given a binding and the author’s schema, it returns the schema the provider’s client sends (EffectiveSchema), whether the request is strict, and the three lists above. A provider that does not implement it adds nothing to the report.

  • UseGoogleSpeech registers a Gemini text-to-speech model as the named provider gemini, with GoogleSpeechOptions (Model, Name, MaxCharactersPerRequest, MaxDialogueCharactersPerRequest, DialogueSpeakerMode, Voices, UseAsDefault). One instance speaks one voice (POST /api/voice/speak with "provider": "gemini" answers a WAV file) and voices a dialogue of exactly two voices through GetDialogueSynthesizerAsync. It shares the client of UseGoogle and the provider name google, so one egress rule covers chat, images and speech, and a tenant with its own Google key is served with that key on the client its chat requests use. The speaker format of a dialogue depends on the model generation, and the wrong one answers 400: set DialogueSpeakerMode to PartMetadata (the gemini-3.8 models, the default) or TextLabels (gemini-2.5-flash-preview-tts, gemini-3.1-flash-tts-preview). The audio is a WAV file or raw 16-bit PCM (OutputFormat wav or pcm); a stream is raw PCM. Character timing is refused, and billing is an estimate.

  • SpeechProviderDescriptor.Dialogue and GET /api/voice/providers report whether a speech provider can voice a dialogue. The value is the provider’s SpeechDialogueCapabilities (most voices, most characters, whether it reports timing) or null. A caller no longer learns it from the failure of GetDialogueSynthesizerAsync. The OpenAPI document and both generated clients carry the new field.

  • SpeechDialogueCapabilities.Check(request, providerName) is the rule a dialogue implementation applies before it calls its service. The built-in ElevenLabs client and the Gemini synthesizer use it, with the same messages; a synthesizer of your own calls it to refuse an empty turn, a turn without a voice, too many voices or too many characters, and never to split or cut the dialogue.

  • A front end that speaks the AG-UI protocol can run a Tracon agent. The new package Tracon.AGUI adds MapTraconAGUI() (POST /tracon/agui/{agentName}, after MapTracon()): the body is an AG-UI RunAgentInput, the answer is the AG-UI event stream. It is a Tracon run: recorded, tenant-scoped, role-, key-scope- and session-owner-checked, counted against the quota and priced, with the same protection as MapTracon. The thread is the session, and only the messages after the last assistant message are new input. A tool the front end declares never becomes a tool the model can call: a client-side tool registered with AddClientTool and named in the agent runs on the front end with the schema from the code, a carried client-side tool the request does not declare is 400, and a declared tool the agent does not carry is ignored and recorded on the run (RunEventCustomTypes.AGUIToolsIgnored). A tool that waits for approval, and an MCP server’s request for input, end the run with an interrupt; the answer is the next request’s resume. The approval is audited as approval.decision with "surface": "ag-ui", before it is applied. The first event after RUN_STARTED is a CUSTOM event named tracon.run that carries the Tracon run id. The package takes the AG-UI SDK (AGUI.Server, AGUI.Abstractions) and is not part of the Tracon meta package. See AG-UI front ends.

  • A tool can ask the user for information in the middle of a call (MCP elicitation). Tracon now tells every MCP server it connects to that it can answer such a request, with form fields only. In a run started with POST /api/agents/{name}/run (or over the AG-UI endpoint) and a sessionId, the request stops the run as AwaitingInput: the stream sends an inputs event right before done (a response with Idempotency-Key carries inputs in its body), one entry per request with requestId, toolName, serverName, message and requestedSchema. Answer in the next request of the session with AgentRunRequest.InputResponses (ToolInputResponse: RequestId, Action of ToolInputAction Accept, Decline or Cancel, and Content for Accept). The tool call then runs again from its start with the answer, so a server must take the same call twice; the replay asks no second approval. Every other request gets an explicit decline and, inside a run, a tracon.tool.input.declined run event (RunEventCustomTypes.ToolInputDeclined) that names the reason: a run without a session, an OpenAI-compatible call, an A2A task, an MCP tool call, a queued run, an agent called by another agent, a request to open an address (url mode), and a server on the earlier protocol. A tool call stops for input at most once: if the server asks something else the second time, it gets decline, never the old answer. A wrong answer is 400 (unknown or repeated requestId, a field the server did not ask for, a required field left out, a value of the wrong type) or 409 (already answered); another user’s session is 403, as for every run. The values go to the server and nowhere else. The audit entry tool_input.answer names the request, the server, the tool, the action and the names of the fields, never a value, and it is written before the call runs again: it is the eighth fail-closed audit operation (the published set of seven grows by one), so an answer that cannot be recorded is not applied. The playground shows a form made from the requested schema and says which server the answer goes to. RunStatus.AwaitingInput now also appears on the root agent run of a suspended call, and its RunAwaitingInput event carries requestId, toolName and serverName.

  • An agent can keep files in a private workspace for each session. Set AgentDefinition.Workspace (WorkspaceSettings: Enabled, ReadOnly, DisableReadApproval; also AgentDefinitionRequest.Workspace, AgentDescriptor.Workspace and workspace in the HTTP body). The agent then lists, reads, searches, writes, edits and deletes files with the eight file_access_* tools of the Microsoft Agent Framework, in an area that belongs to one session of one agent: another session does not see the files, and they are not the agent’s file memory. Tracon runs each tool through the same chain as a tool registered in code: your IToolAuthorizationHandler, argument validation, the tool timeout and the approval step. The tools that change a file (write, replace, replace lines, delete) always ask for approval; DisableReadApproval only reaches the tools that read, and ReadOnly offers the tools that read and nothing else. Tracon:Workspace (TraconWorkspaceOptions) limits a file (256 KiB), the files of a session (5 MiB) and the workspaces of a tenant (500 MiB), and a file also counts 1 KiB for its row; a write past a limit is refused, the model reads “Workspace quota reached”, and the run goes on. The files go with their session: DELETE /api/sessions/{id}, a data subject erasure (its preview, export and delete include them under session_workspace) and the sessions retention target delete them. A run with no session, a workflow run and a replay have no workspace. A host that exposes an agent over A2A or MCP must make its workspace read-only with DisableReadApproval, or the host refuses to open the agent. The console has three boxes for it, and the sample has a workspace-drafter agent. A tool of your own cannot take the name of one of the eight file tools, an agent that has a workspace cannot have a / in its name, and an agent with file memory cannot be named ~workspace…. The automatic continuation of an interrupted run refuses an agent that has a workspace. The default file store is in memory and loses the files at a restart: use a SQL provider. SQL Server counts the UTF-16 bytes it stores, so a limit there holds half as many ASCII characters. Migration 0064 (PostgreSQL), 0052 (SQL Server) and 0051 (SQLite) add an index on agent_files (tenant_id, path); the queries behind the limits and the deletion would otherwise read the whole table.

  • An agent remembers what its user said in earlier sessions. Set MemorySettings.EnableConversationMemory on a definition, call UseConversationMemory(options => options.VectorDimensions = …) once, and register a Microsoft.Extensions.VectorData.VectorStore. The agent then finds what the same user told it in an earlier session and gets it as context, before each turn (ConversationMemorySearch.BeforeEachTurn, the default) or through a search_conversation_memory tool the model calls (OnDemand). A record carries the tenant, the agent, the user and the session; a search looks at tenant, agent and user, so a user is remembered across sessions and never seen by another user, agent or tenant. A run with no user or no session neither reads nor writes. Only user and assistant text is stored, not tool results. The records go with their session: DELETE /api/sessions/{id}, a data subject erasure (its preview, export and delete include them under conversation_memory) and the sessions retention target delete them first. The store embeds the text, so that model call is not on the run’s usage. The console has one box and one choice for it. Check the connector first: the Microsoft.SemanticKernel.Connectors.* packages (1.74 preview) call a member that Microsoft.Extensions.VectorData.Abstractions 10.7 removed, and fail at search time without a build warning; CommunityToolkit.VectorData.* 1.0 works. A workflow run, and the agent steps below it, remember nothing: the workflow’s session id never gets a session record, so nothing could delete what they kept. The records hold the message text in plain text, in your store, outside content protection.

  • A definition can turn the approval of the two skill read tools off. Microsoft Agent Framework asks for approval before load_skill, read_skill_resource and run_skill_script, so an agent with skills could not run where nobody can answer: an A2A or MCP call, a run with no session, a scheduled run. The new SkillSettings record (AgentDefinition.Skills, AgentDefinitionRequest.Skills, AgentDescriptor.Skills, and skills in the HTTP body) has one member, DisableReadApproval. With true, load_skill and read_skill_resource run without approval. run_skill_script keeps its approval: it runs code, so no member of the definition turns that off (a standing approval rule can still approve it). The default is unchanged, a definition stored before this release reads back with all three tools asking, and the console’s skills section has one box for it. A definition that sets Skills but names no skill gets the validation warning skill_settings_without_skills. Skill text reaches the model as instructions, so turn the box on only for skills that a tenant administrator writes.

  • A workflow waits for a tool that needs approval, and a person answers it. Before, an agent step whose tool needs approval failed the whole workflow unless an auto-approval rule existed. Where a Microsoft Agent Framework pattern hosts the agent (a stored Sequential, Concurrent, Handoff, GroupChat or Magentic workflow defined with agentNames, and a code graph that binds the agent with GetWorkflowAgent), the workflow now stops: the step’s run closes AwaitingApproval, the workflow run closes AwaitingInput, and the request has the new form WorkflowRequestForm.ToolApproval, whose prompt names the tool and adds what your IToolApprovalPresenter says about the call (the arguments are not copied). Answer it with POST /api/workflows/runs/{runId}/respond: approved, an optional reason in text that the agent reads, and remember (new on WorkflowRespondRequest) to save a rule for that agent and tool. The answer carries a decision only: the tool call comes from the request the workflow recorded, so a data value is refused with 400, and so is remember with approved: false or on any other form. The decision is written to the audit trail as approval.decision before the workflow continues; an entry that cannot be written refuses the answer. The tool runs once, in the new run the answer opens, in whichever process takes the answer. A request nobody answers closes the step that waited with the workflow when its time limit passes. The console’s workflow card asks for the decision, a reason and the rule. An agent node of a Sequential node list and a step bound with GetWorkflowAgentStep run the agent in a plain function node that cannot wait, so a tool that needs approval still fails the run there, and so does a tool of the Magentic manager; an agent that a step’s agent calls as a sub-agent still cannot ask. A tool approval is answered once, whatever the workflow’s response mode: the same decision again follows the run the first one started, and a different one is 409.

  • An A2A conversation keeps its history, and it belongs to the caller that started it. A message that carries a contextId now continues a conversation: Tracon stores the session and the agent sees the earlier turns. Before, the reply carried the contextId back but the agent saw one message, so a caller believed it was in a conversation that did not exist. A conversation is stored under the tenant, the agent, the caller and the contextId, and a caller that sends the contextId of another caller starts a new conversation. The caller is the signed-in user (Tracon:Audit:ActorClaimType applies), the API key, the static AuthToken, or, with no identity at all, the local operator; all callers without an identity share one conversation space, and a signed-in user whose identity cannot be read is refused. The session identity is a2a: followed by a SHA-256 digest of the four parts, so a contextId of any length fits the session identity column. Each turn re-sends the history to the model, so the tokens per message grow with the conversation. UseA2A() registers the store for each exposed agent and an AgentIsolationKeyProvider; a store or a provider the application registers itself wins. A message without a contextId is still a single turn, but Tracon stores a session for it too (it makes up a contextId and the reply carries it), and session retention is off by default; set a maximum age for the sessions target to bound the table. The change adds no migration.

  • An eval case lists the tool calls it expects, with their arguments, and toolCallArgsMatch checks them. EvalCase.ExpectedToolCalls is a list of EvalExpectedToolCall: a tool Name and optional Arguments. The new check kind toolCallArgsMatch passes a case when, for every entry, the agent called a tool of that name and one such call carried every listed argument. The listed arguments are a subset (the call may carry others) and a value compares by its JSON meaning: 2 equals 2.0, an object equals one with the same members in any order, and a null value means “absent or null”. Microsoft Agent Framework’s own ToolCallArgsMatch compares a number by its .NET type and an object by its text, so an expected 2 never matched; Tracon’s check does not use it. The reason of a failed case names each entry (not called, args mismatch) and never repeats an argument value. A custom evaluator receives the same calls on EvalItem.ExpectedToolCalls, as Microsoft Agent Framework reads them. The console’s case editor takes a tool name and a JSON object of arguments for each call and refuses an argument text that is not a JSON object. Promoting a reference run writes the names of the tools the run called, without arguments. The change adds eval_cases.expected_tool_calls (migration 0063 PostgreSQL, 0051 SQL Server, 0050 SQLite). A cell of that column that SQL Server or SQLite holds as text and that is no longer the array Tracon writes reads as an entry no run can call, so the case fails instead of passing with nothing to check, and the listing still works. A custom IEvalStore meets this in Tracon.Testing.Contracts.Xunit: EvalStoreContract adds EvalStoreContract.ReplaceCasesAsync_round_trips_an_expected_argument_of_every_json_kind, EvalStoreContract.ReplaceCasesAsync_reads_back_an_empty_list_for_a_case_that_expects_no_tool_call and EvalStoreContract.A_row_written_before_expected_tool_calls_existed_reads_back_its_names, and renames ReplaceCasesAsync_stores_expected_tools_and_context to EvalStoreContract.ReplaceCasesAsync_stores_expected_tool_calls_and_context. The last one needs three hooks a store on SQL overrides (SupportsRawSql, ExecuteRawAsync, QualifiedEvalCasesTable, and FormatIdForRawSql when it spells an id otherwise); a store without SQL skips it.

  • A host registers a tool the model provider runs, and a definition selects it by name. AddHostedTool(name, kind, factory) registers a web search, a code interpreter or a file search (HostedToolKind); the factory receives a HostedToolContext with the tenant, the provider of the agent’s model and the services, and builds the Microsoft.Extensions.AI tool for that tenant. A definition lists the name in ToolNames, the same list as a function tool. The provider runs the tool inside the model call, so no approval, permission or timeout applies. A provider states which kinds its API surface runs through the new IHostedToolSupport (HostedToolKinds); a definition that selects a kind the provider would drop, on the primary model or on a fallback, is refused when the agent is built and by POST /api/agents/validate (hosted_tool_unsupported, with the path of the tool name), instead of running without the tool. Measured against the request each adapter writes: openai-responses runs all three kinds, anthropic and google run the web search and the code interpreter, openai (Chat Completions) and azure-openai run none. An agent selects at most one hosted tool of each kind; the cached compiled agent follows what the factory builds, so a tenant that replaces its vector store reaches the next run. GET /api/tools lists hosted tools with ToolDescriptor.HostedKind, the console shows a badge, and the sample has hosted-openai, hosted-claude and hosted-gemini.

  • A hosted tool call is priced. Tracon:Pricing:HostedTools:{provider}:{kind}:PerCall sets the price of one call. The amount joins the run’s non-token cost, so the budget, the quota and the run.completed webhook count it once, and a budget the call used up stops the next model turn. A kind with no price is listed on the run and not added to its cost (the run’s UnsummedItemCount); it is never priced at zero, and the host logs one warning. The code interpreter is billed by container and its price per call is an estimate. When Anthropic bills more searches than the response shows, the difference is added. The harness’s own web search is priced the same way; before, no hosted call had a cost. ToolUsageUnits.Calls names the unit.

  • An agent calls a callable agent with one tool call. SubAgentSettings.CallMode (SubAgentCallMode.Background, the default, or Tool) picks what the model sees. With Tool, each agent in CallableAgentNames is one tool named after the agent (each run of characters other than letters and digits becomes one underscore), instead of Microsoft Agent Framework’s background task tools. The sample router agent asked one question took 5 model calls and about 3,600 input tokens with background tasks, and 2 model calls and about 410 input tokens with Tool (real OpenAI gpt-5.4-mini, three runs each); for work that runs in parallel, background tasks stay the better choice. The depth limit, the budget of the tree, the tenant check, the wait limits and the refusal of a sub-agent that needs tool approval apply in both modes. A callable agent whose tool name another tool of the agent already has (ToolNames, a skill tool, search_knowledge, another callable agent) is refused by validation and save (sub_agent_tool_name_conflict, 400) and by the compiler. The agent editor has a control for the mode and the version compare shows it. A sub-agent that fails because its model provider is down, or because it no longer exists, answers the parent with the reason as the tool result, as a background task does. A row saved before this setting reads as background tasks.

  • A definition sets the model’s request fields and the tool-call loop. ModelBinding gains Seed, TopK, StopSequences, FrequencyPenalty, PresencePenalty, ToolMode (auto, none, required or required:<tool>), AllowMultipleToolCalls and ReasoningOutput (None, Summary or Full), and ToolLoop (ToolLoopSettings: AllowConcurrentToolCalls, MaximumConsecutiveErrors, TerminateOnUnknownCalls). They are part of the definition, so a version, an experiment variant and a replay carry them; before, only an agent extension could set most of them and Reasoning.Output could not be set at all. A provider states which of the fields its API surface sends through the new ISamplingSupport (SamplingFields). A definition that sets a field the provider would drop, on the primary model or on a fallback, is refused when the agent is built and by POST /api/agents/validate (sampling_field_unsupported, with the path of the field), instead of running without it. The shipped providers state what the model adapters send; the table is in Model providers. Tracon.Testing’s FakeModelProvider accepts every field, and the agent editor has controls for all of them.

  • Model content and workflow events Tracon has no type for are recorded, not dropped. A hosted tool call or result (web search, code interpreter, image generation, hosted MCP), a provider ErrorContent, generated or attached data, a file reference, and any content type Tracon does not know yet are written as a Custom run event of type tracon.content (RunEventCustomTypes.Content). A WorkflowEvent that the workflow stream does not map, such as an event of your own that an executor of a code workflow raises, is written as tracon.workflow.event (RunEventCustomTypes.WorkflowEvent); its warning in the log is now a debug entry. The payload holds no data bytes, no URI, no tool inputs or outputs; its free text is the provider’s error message and the text of a string workflow payload, cut at 2 048 characters, and any other workflow payload is named by its type only. Both payloads follow RecordToolPayloads. Unlike the quota and truncation notices, tracon.content is not sent on the live POST /api/agents/{name}/run stream; a workflow run’s stream sends tracon.workflow.event like every recorded event. Plain text with RecordMessageDeltas off, reasoning with RecordReasoningDeltas off, usage and approval content stay as they were. Read the events from GET /api/runs/{id}/events.

  • A named agent extension reaches Microsoft Agent Framework options on a compiled agent. AddAgentExtension(name, configure), AddAgentExtension<T>(), and an instance overload register code that sets a framework option, adds an AIContextProvider, or wraps the agent with context.Use. A definition selects it with the new ExtensionNames list, in call order, and the agent editor offers the registered names (GET /api/agent-extensions). The agent still goes through the definition compiler, so it keeps the model rings, the tenant’s own provider key, the egress policy, and cost. The compiler refuses a change to an option the definition or a tenant and security decision owns, and names the extension and the option. AgentDefinition, AgentDescriptor, and AgentDefinitionRequest gain ExtensionNames. A provider an extension adds can add tools, and those skip the tool chain; a replay that plays tools back does not run extensions.

  • Any Microsoft.Extensions.AI speech client serves a named Tracon speech provider. AddSpeechSynthesizer(name, TextToSpeechClientBinding) and AddSpeechTranscriber(name, SpeechToTextClientBinding) take the client from the container, by service key or unkeyed. Every call passes the tenant’s egress policy against the binding’s provider name; a tenant with its own key for that provider is refused; the host stops at startup when the client is missing or has no provider name. The binding declares the character limit and the voice list, which the client cannot report. Billing is an estimate, character timing and dialogue are refused. No public member names a Microsoft.Extensions.AI speech type.

  • UseOpenAISpeech registers OpenAI text-to-speech and speech-to-text as the named providers openai, with OpenAISpeechOptions (SynthesisModel, TranscriptionModel, names, MaxCharactersPerRequest, Voices, UseAsDefault). Each name is bound to one model. The clients run on the shared OpenAI client’s pipeline and not on AudioClient, so a host publishes with Native AOT.

  • A factory agent built outside the model registry logs one warning. When the chat client of an AddAgent(name, factory) agent did not come from IModelProviderRegistry, the catalog writes a warning the first time it resolves that agent name, because its model calls skip the model rings. An agent that exposes no chat client gets no warning, and a resolve never fails because of the check. The guide “Use any Microsoft Agent Framework feature” compares the decorator, the factory agent, and the custom agent source by what each keeps, and samples/Tracon.Samples.MafFeatures builds all three.

  • TraconErrorTypes lists the error codes of runs, jobs and OpenAI-compatible errors. One class holds them in one place: the type of a failed run (tool_timeout, content_blocked, run_budget_exceeded, …), the codes of a job the queue failed (tracon.job.attempts-exhausted, …) and the error.type of the OpenAI-compatible endpoints (model_not_found, …). The *ErrorType constants of the exception classes (TraconToolTimeoutException.ToolTimeoutErrorType and the others) and JobErrorCodes stay, and they carry the same values; no value changed, so no stored run record and no client that matches a code is affected. The codes of the findings that POST /api/agents/validate returns (unknown_tool, cycle, …) are listed the same way and are not public members. A code never changes inside a major version.

  • The release check compares HTTP, SSE, telemetry, configuration keys, error codes and the CLI with the last published version. Until now only the .NET API had a check against the last release. From 1.0.0-rc.1 a removed or renamed endpoint, response field, event name, instrument, attribute, configuration key, error code, CLI command or option stops the release, and so does a change that the rules do not name. A patch release does not add to any of them. See What 1.x promises for HTTP, telemetry, configuration, error codes and the CLI.

  • One tenant can no longer take every workflow slot of a process, or the front of the job queue. The new Tracon:Workflows:MaxConcurrentRunsPerTenant (TraconWorkflowOptions.MaxConcurrentRunsPerTenant, off by default, from 1 to MaxConcurrentRuns) limits how many workflow runs one tenant has going at the same time, in two places. In a process, a run beyond the tenant’s limit waits for a tenant slot before it takes a shared slot, so it holds nothing while it waits and never delays another tenant. In the job queue, a worker does not lease a queued workflow job (tracon.workflow, tracon.workflow-run) of a tenant that already holds that many leased, and leases the oldest job of another tenant instead; this count covers every process that shares the store, and it is read, not locked, so workers that lease at the same moment can take a tenant past the limit by up to their own number. The wait for a tenant slot counts toward RunTimeout, as the wait for a shared slot does. The setting is checked at startup, and a value that is not a number stops the host. See workflow concurrency.

  • Another agent can be the stop criterion of a loop: agentReview. A LoopCriterion with Kind = "agentReview" names a reviewer agent of the catalog in the new ReviewerAgent, and optionally a version in the new ReviewerAgentVersion. After each iteration the reviewer reads the request, the iteration number and the draft (the last assistant message), and answers with {"approved": true|false, "feedback": "..."}: true stops the loop, false sends the feedback to the agent as the next input. The reviewer runs as a child run of the run under review, through the same door every sub-agent call uses, so the tenant check, the depth limit, the run and token budget, the quota, the wait limit and the refusal of a tool that waits for approval all apply. A refused call, a failed call and an answer that is not that object are failures of the criterion, and OnCriterionError decides what follows; an answer that cannot be read is never an approval. The shape is the reviewer agent’s own response format, not an option the loop sets on the call. Validation reports loop_reviewer_missing, loop_reviewer_self, loop_reviewer_version_invalid and loop_reviewer_not_found, and warns with loop_reviewer_verdict_unknown when the reviewer has no JSON schema with a boolean approved and a string feedback. The console’s Loop panel lists the agents and takes the version. GET /api/loop-criteria lists the kind. See Another agent as the reviewer.

  • The LoopIterationCompleted event says who decided. Its payload has a new criteria list: every criterion evaluated in that iteration, in order, with its kind and decision (stop, continue or failed). An aiJudge entry names the judge (provider and model of the call, the fallback model when the judge’s own model failed over), and an agentReview entry names the reviewer (agentName, agentVersion and the runId of its child run). The event text names the reviewer too. The judge’s reasoning, the reviewer’s answer, the draft and the feedback are not written. The loop.iteration-completed frame of the native stream carries the same data. A reader that ignores a field it does not know needs no change.

  • The run stream of an agent with a loop says where each iteration ends. POST /api/agents/{name}/run already streamed every iteration, but nothing in the update frames marked the border, so a client that read the stream could take the first draft for the answer. After each iteration the loop evaluated, the stream now sends a loop.iteration-completed frame, between the last update of that iteration and the first of the next. Its data is the recorded LoopIterationCompleted event, byte for byte what GET /api/runs/{id}/events serves. The answer is the iteration the last frame closes when it has continued: false; when it has ceilingReached: true, the answer is the iteration after it, because the turn at the ceiling is not evaluated. The update after a frame with continued: true is the loop’s feedback to the model (role user), not the model’s text. The event’s payload is null when RecordToolPayloads is off. An agent without a loop, an event the store could not record and a sub-agent’s loop send no frame. Recorded event timestamps now have whole microseconds, so the frame and /events agree on every store. The console playground draws a divider at each frame. A client that ignores a frame name it does not know needs no change. See Streaming.

  • AgentDefinition.CallableAgentNames is now CallableAgents, a list of CallableAgentReference (breaking). Rename the initializer; a name converts to a reference, so CallableAgentNames = ["support"] becomes CallableAgents = ["support"]. AgentDefinitionRequest.CallableAgentNames is CallableAgents too, and over HTTP the field is callableAgents, a list of { "name": ..., "version": ... } objects. A request body that still carries callableAgentNames is refused with 400 and names the new field, so it cannot drop the callable agents in silence. The validation path of a tool name that another tool already has moved from callableAgentNames[i] to callableAgents[i].name. AgentDescriptor.CallableAgentNames (the catalog’s list of names) is unchanged. Upgrade note: no migration. A stored definition that holds the old key reads back as entries with no version, and takes the new shape the next time it is saved; a custom IAgentDefinitionStore must keep the version of each entry (AgentDefinitionStoreContract now checks it).

  • IAgentDefinitionStore never gives a version number twice for one name. SaveAsync returns one more than the highest version the name ever had, including the versions of a definition that was deleted; DeleteAsync still removes the definition and every one of its versions and keeps only the number. A custom IAgentDefinitionStore must keep that number too: AgentDefinitionStoreContract has eight more cases that a store which restarts at 1 now fails: AgentDefinitionStoreContract.SaveAsync_after_DeleteAsync_continues_the_numbering, AgentDefinitionStoreContract.The_numbering_survives_every_delete_not_only_the_first, AgentDefinitionStoreContract.DeleteAsync_of_a_name_that_never_existed_does_not_start_a_numbering, AgentDefinitionStoreContract.RollbackAsync_after_a_recreate_continues_the_numbering, AgentDefinitionStoreContract.RollbackAsync_for_a_deleted_agent_throws_and_keeps_the_numbering, AgentDefinitionStoreContract.One_tenants_delete_does_not_move_another_tenants_numbering, AgentDefinitionStoreContract.Concurrent_first_saves_of_a_new_name_get_distinct_versions and AgentDefinitionStoreContract.Concurrent_saves_of_one_name_get_distinct_versions_across_a_delete. The PostgreSQL, SQL Server and SQLite migrations add the agent_definition_version_counters table (0065, 0053 and 0052), one row of name and number per agent and no agent content, and start each existing agent’s counter at the version it holds today. Upgrade note: an agent that was deleted and created again before the upgrade cannot be recovered; its earlier numbers were never kept, so its counter starts at the highest number it holds now. During a rolling upgrade an older process may still save an agent without touching the counter: the next number is one more than the larger of the counter and the agent’s own version, so such a save never collides with the history it wrote.

  • A configuration value that cannot be read stops the host at startup. A key that held text its type could not read (Tracon:Tools:DefaultMaxOutputBytes = "10MB", Tracon:RunRecording:RecordToolPayloads = "nope", Tracon:RateLimit:Enabled = "yes") was skipped and the shipped default stayed, with no message. 25 of those defaults are the loose side, so a typo left a limit open or a protection off. Now every number, flag, time span and enum name in the Tracon:* sections and in the package sections (Mcp, Voice, Voice:Live, Voice:Conversation, the providers, the SQL stores) must be readable, and so must the numbers of a model catalog entry and of a price. The host refuses to start and the message names the full key and quotes the text: Tracon:Tools:DefaultMaxOutputBytes is '10MB', which is not a 32-bit whole number. Every unreadable key of the same options type is listed in one message. Empty text, or only white space, is still “not given” and keeps the default. Code that assigns the option after binding still wins, so a typo you have overridden in a Configure callback does not stop the host. Upgrade check: start the application once; the keys it names are the ones that were ignored until now.

  • A list key that holds one value is refused. Tracon:Skills:Scripts:EnvironmentAllowList=PATH was ignored and the default list stayed. The message now says to write …:EnvironmentAllowList:0. A JSON [] and a key with no text keep the default list, as before; to empty a list, clear it in code. This covers every list key (Lanes, HttpSchedulableHandlerKeys, DeniedTerms, AgentNames, AllowedMediaTypes, ThresholdPercents, RetryDelays, Columns).

  • An entry of a list or a map that cannot be read is left out and named by its position or name (Quotas:ThresholdPercents:1, Scheduling:MaxConcurrentJobsPerLane:media). It used to be bound as 0 (or as an undefined value) so that the options validator would complain about the stand-in; the message now quotes what you wrote.

  • A decimal no longer takes a thousands separator. Tracon:Pricing:…:Input = "1,5" was read as fifteen. It is now an unreadable value; write 1.5.

  • Tracon:Voice:Live:DelegationMode is refused by the same rule instead of throwing while the options are bound: the host stops at startup with an OptionsValidationException that names the key and the one mode, Client.

  • An address key that is not an absolute address is refused by the same rule. Tracon:Voice:Endpoint, Tracon:Providers:OpenAI:Live:Endpoint and Tracon:Mcp:OAuthCallbackBaseUri ignored text such as gateway.example.com/v1 and kept the default, so speech and live sessions went to the provider’s public address. The host now stops and names the key: … is 'gateway.example.com/v1', which is not an absolute address such as https://example.com/.

  • A map key that holds one value is refused. Tracon:Scheduling:MaxConcurrentJobsPerLane=5, …:MaxDurationByHandlerKey=00:10:00 and …:LaneByHandlerKey=media name no lane or handler and were ignored. The message now says to write …:MaxConcurrentJobsPerLane:<name>.

  • The knowledge search and the on-demand conversation memory search run through the tool chain. search_knowledge (MemorySettings.EnableVectorSearch) and search_conversation_memory (ConversationMemorySearch.OnDemand) were added to a run as bare functions, so your IToolAuthorizationHandler, your IToolArgumentsValidator, the tool timeout and output limit, and the tree budget never saw them, and the refusal of a tenant’s egress policy reached the model as “Error: Function failed.” Now both pass the chain a tool registered in code passes. An authorization handler that denies ToolEffect.External calls now also closes both searches: it is asked with the tool name and Effect = External, because the text of the query leaves the process, and the model reads your reason as the result. A validator that rejects a tool it does not know rejects them too. Neither tool is in IToolRegistry, so GET /api/tools does not list them; write their names in your policy as fixed text. A spent budget stops the search before the query is embedded; the whole call, the embedding included, ends at Tracon:Tools:DefaultTimeout (30 seconds by default); and a hit reaches the model as text, trimmed at Tracon:Tools:DefaultMaxOutputBytes like any tool result. The call line of search_knowledge in GET /api/runs/{id}/tools now has a usage: unit tokens, the input token count of the embedded query as the provider reported it, and no cost, so it adds to no budget, quota or webhook total; the run’s nonTokenCost counts it under unsummedItemCount. An embedding has no price in Tracon yet. See Embedding Tracon.

  • A tool call does not start once the call tree’s budget is spent. The budget (Tracon:AgentGraph:MaxTotalTokens, MaxTotalCost, MaxDuration) already refused the next model call, a new agent step and a workflow function node. A tool call was the one start it did not read, so when one model turn asked for two paid tools and the first passed the ceiling, the second still ran: its money and its side effect were gone, and the model call that would have read its result was refused. Now the second tool does not run. Its result is the limit, the setting to raise and “The tool call did not start.”; a streaming run (the HTTP run is one) records the call as failed; and the run ends at its next model call as before (Failed, run_budget_exceeded). All three limits stop a tool call, MaxTotalTokens included, since a model reads its result; a function node is still stopped by cost and time only. The check covers tools registered in code, session workspace tools, MCP tools, the knowledge and conversation memory searches and skill scripts, and it runs before your IToolAuthorizationHandler. A tool that has started is never stopped, so the call that crosses the ceiling is the last one that starts; with ToolLoop.AllowConcurrentToolCalls on, the tools of one turn start together and all of them start. A hosted tool runs inside the model call and is not checked. A host that sets no ceiling still has the default MaxTotalTokens of 200,000: a tool that a turn asks for after the tree has passed it no longer runs. See Bound multi-agent trees.

  • agentReview is now a built-in loop criterion kind, so a criterion registered in code under that name is refused. AddLoopEvaluator("agentReview", ...) (in any casing) throws TraconException at startup, the way the other four built-in names do. A definition that named such a kind now means the built-in one; rename your criterion before you update.

  • A workflow run that waits for a concurrency slot is Queued, not Running. The run’s record still opens before the wait, but it reads Queued while the run waits for a slot of MaxConcurrentRuns or of MaxConcurrentRunsPerTenant, and Running once it has them. A run that finds a slot free opens Running as before. GET /api/runs, the run’s detail and the console show it (the queued badge was already there). A dashboard that counted Running rows as “using a slot” now counts only the runs that have one. POST /api/runs/{id}/cancel reaches a waiting run (202, closed Canceled); until now it sent every Queued run to the job queue.

  • A Queued run row can be closed by run reconciliation. IRunStore.TouchHeartbeatAsync now marks Queued rows as well as Running ones, and IRunStore.ClaimOrphanedRunsAsync closes a Queued row whose heartbeat is older than the threshold as Failed (orphaned). A process writes a heartbeat for the runs it executes, and for a workflow run that waits in it for a slot, so a Queued row with a heartbeat is a run whose process is gone. A Queued row that never sent one is still the job queue’s and is never closed by reconciliation. An IRunStore you implement must do the same: the shared contract tests (RunStoreContract) have three new cases, RunStoreContract.ClaimOrphanedRunsAsync_closes_a_Queued_row_whose_heartbeat_is_stale, RunStoreContract.ClaimOrphanedRunsAsync_does_not_touch_a_Queued_row_with_a_fresh_heartbeat and RunStoreContract.A_run_promoted_from_Queued_to_Running_keeps_the_heartbeat_it_sent_while_it_waited, and ClaimOrphanedRunsAsync_never_touches_a_Queued_row is now RunStoreContract.ClaimOrphanedRunsAsync_never_touches_a_Queued_row_that_has_no_heartbeat.

  • Breaking for implementers (preview): IJobStore.LeaseAsync(owner, leaseDuration, lanes, cancellationToken) is now IJobStore.LeaseAsync(JobLeaseRequest request, cancellationToken). JobLeaseRequest carries Owner, LeaseDuration, Lanes and the new TenantCap (JobTenantCap: the handler keys and MaxLeasedPerTenant); a new option is a new property, not a new parameter. An IJobStore you implement takes the request and, when TenantCap is set, skips a ready job of a handler key in the cap while its tenant holds that many live leases on those keys (an expired lease does not count). A caller reads request.Lanes where it read lanes. JobStoreContract has eleven new cases for the cap: JobStoreContract.Lease_without_a_tenant_cap_gives_one_tenant_the_whole_queue_in_age_order, JobStoreContract.Lease_with_a_tenant_cap_leases_up_to_the_cap_for_one_tenant, JobStoreContract.Lease_with_a_tenant_cap_skips_a_tenant_at_the_cap_and_leases_the_oldest_job_of_another, JobStoreContract.Lease_with_a_tenant_cap_counts_a_running_job, JobStoreContract.Lease_with_a_tenant_cap_counts_both_workflow_handler_keys_together, JobStoreContract.Lease_with_a_tenant_cap_does_not_count_a_job_of_another_handler_key, JobStoreContract.Lease_with_a_tenant_cap_does_not_count_a_lease_that_has_expired, JobStoreContract.Lease_with_a_tenant_cap_does_not_count_the_leases_of_another_tenant, JobStoreContract.Lease_with_a_tenant_cap_still_honors_the_lane_filter, JobStoreContract.Lease_with_a_tenant_cap_still_leases_a_job_of_another_handler_key_of_a_tenant_at_the_cap and JobStoreContract.Lease_with_a_tenant_cap_that_names_no_handler_key_or_no_room_is_refused.

  • tracon.tenant.id is no longer on metrics by default. The metrics tracon.runs, tracon.run.cost, tracon.judge.cost, tracon.judge.score, tracon.model.cache, tracon.audit.write_failures, tracon.run.recording_failures and tracon.job.executions carried the tenant on every series, which multiplies each series by the number of tenants. They now carry it only when Tracon:Observability:IncludeTenantTag (TraconObservabilityOptions.IncludeTenantTag) is true; it is false by default. Spans and the quota gauges (tracon.quota.usage, tracon.quota.limit) carry tracon.tenant.id as before. A dashboard or an alert rule that filters or groups one of the eight metrics by tracon.tenant.id shows no series until you set the key. A value that is not true or false stops the application at startup. See the tenant tag on metrics.

  • A workflow run that continues an earlier run no longer starts with a budget of zero. A person’s answer to a request, an operator’s resume (POST /api/workflows/runs/{runId}/resume) and a recovery after an interruption each start a new run row, and each used to build a new call-tree budget: a workflow with an approval loop got the whole Tracon:AgentGraph ceiling again with every answer, and a run that failed for its budget was resumed into the node that had been refused. The ceiling is now the logical run’s. The new run starts from what the runs before it spent (tokens, cost and child runs), and the time limit counts the time the chain’s runs were executing, not the time the workflow waited for a person. A workflow that stayed under its ceiling only because every answer got a new one now fails at the node that crosses it, with run_budget_exceeded: raise MaxTotalCost, MaxTotalTokens, MaxTotalRuns or MaxDuration to the size of the whole review, not of one round. A chain that cannot be read (the run store does not answer, or it is longer than 128 runs) ends the run Failed before its first node. A run that an interruption closed holds what its function nodes reported only in its items, and Tracon reads them from there. A nested sub-agent call is spent but not counted as a started run. See the ceiling of a workflow.

  • An operator’s resume of a workflow run records the run it resumes in continuedFromRunId. Until now only a recovery wrote the link, so the resume run looked unrelated to its source and GET /api/runs?continuedFromRunId= did not list it. A run that answers a person’s request still does not carry it; the answer names the run that waited in its own history. A continuation that a recovery opened keeps the link the recovery wrote. The resume run joins the chain the recovery and the response gate read, so a repeated answer follows it, and a resume of a run whose queued job may still recover it is recovered with that job: resume a run only after its job has ended.

  • A workflow function node or an agent step is refused once the tenant’s cost quota is spent. The quota was read at the door only (429), so a workflow that waited for a person, or ran for hours, passed a quota that was spent in its first minutes. Before a function node or an agent step starts, the tree asks the tenant’s quota: the cost limit stops a function node, and the token and cost limits stop an agent step or one agent’s call to another. The run ends Failed with the new error type tenant_quota_exceeded (TraconTenantQuotaExceededException, class QuotaExceeded); when one agent calls another, the refusal comes back to the model as a tool result, as a spent call-tree budget does. Only a start is refused, and the check counts what the run has spent that its counter has not seen yet, so the nodes it already finished count. The run limit stays at the door. A node registered with options.StartsWhenBudgetIsSpent = true is exempt from the quota as it is from the budget, and a refusal is never retried by a node’s retry policy. The check reads the tenant’s rules (one read; two when the tenant has a rule) each time a node or step starts, and follows Quotas:AllowOnStoreFailure when the store cannot be read. With Quotas:Enabled = false nothing is read. Quotas are on by default, so a model call is priced in every installation that has not turned them off: AgentRunBudget.ConsumedCost now holds the cost of the model calls even when AgentGraph.MaxTotalCost is not set. The price lookup costs about 1 microsecond per model call (24 models in the catalog). See A quota also stops work that has not started.

  • A data subject erasure deletes the copies of a session outside the SQL rows before it opens its transaction. The copies are the records of conversation memory in your vector store and the files of a session workspace. They used to go after the rows were deleted and before the commit. The files live in the same database, and SQLite lets one connection write at a time: a delete on a second connection, while the erasure held its writes, waited for the busy timeout and failed. A copy that cannot be deleted still fails the call with every row in place, and repeating the call finishes the work. If a later step fails after the copies are gone, the repeat call deletes the rest and its audit entry counts only what was left. The sessions retention target now always reads the expired batch and deletes the files of each session before the rows (an installation with no workspace deletes nothing extra, and the delete is answered from the new index).

  • Model Context Protocol SDK 2.3.0 (ModelContextProtocol.Core, .AspNetCore, .Extensions.Tasks). No public member was removed. A published agent’s tools/call from a client on the 2026-07-28 revision now gets its response headers with the result: a proxy with a time-to-first-byte limit cuts a long call. Use EnableTasks for long calls, or raise that limit.

  • An A2A message is refused before the model is called when session ownership is strict and the caller has no identity to own the conversation. With Tracon:SessionOwnership:Enabled and RequireAuthenticatedOwner on, a conversation needs an owner. A2A wrote no session until now, so the rule never reached it. The caller receives a JSON-RPC internal error (-32603) and the cause is in the server log at Error level. A message that resumes a conversation another user owns is refused the same way, which matters when one API key speaks for several users. Bind IRunAttributionContext to the identity pipeline, or turn RequireAuthenticatedOwner off, before you upgrade an installation that publishes an agent over A2A.

  • EvalCase.ExpectedTools and EvalCaseDraft.ExpectedTools are replaced by ExpectedToolCalls. This breaks source and the JSON of a case. The old field held tool names and no check read it, so a case that filled it asserted nothing; a name-only entry of the new field is checked. PUT /api/evals/{name}/cases answers 400 (Removed field) to a body that still carries expectedTools and names expectedToolCalls; the expectedTools of the case list is gone. A case stored by an earlier build keeps its names: they read back as entries without arguments, the old column stays in the table and is never written, and the next save of the case writes the new column. A custom check can no longer be registered under the name toolCallArgsMatch; it is a built-in kind now.

  • A call of a registered hosted tool is a pair of tool events, not a tracon.content event. The calls of a tool added with AddHostedTool are ToolInvoking and ToolInvoked events under the registered name, with the provider’s call identifier and no payload, and a tool invocation record that carries the call’s measurement (unit calls, quantity 1). A hosted call that no registered tool owns, such as the harness’s web search, stays a tracon.content event as before. A streamed call that the provider shows in several pieces is written once. A replay that plays tools back (ReplayTools) or runs none (NoTools) removes a hosted tool, so it does not search and bill again.

  • ModelBinding.AllowConcurrentToolCalls moved to ModelBinding.ToolLoop.AllowConcurrentToolCalls. This breaks source and the JSON of a definition. A definition stored by an earlier build is read with the old key moved, so no migration runs and the setting is kept; the next save writes the new place only, and so does a rollback. POST /api/agents, PUT /api/agents/{name} and POST /api/agents/validate refuse the old key with 400 (Removed field) and name the new path. A fallback link now takes the whole ToolLoop of the primary model.

  • An agent extension and the options of one run can no longer set Seed, TopK, StopSequences, FrequencyPenalty, PresencePenalty, ToolMode or AllowMultipleToolCalls. The definition owns them now; an extension that changes one fails the compilation and names the option, and ChatClientAgentRunOptions that sets one throws ArgumentException. One exception: a run may set AllowMultipleToolCalls to false, which a handoff step does to hand off to one agent.

  • /v1/chat/completions refuses a model setting the agent owns with 400. temperature, top_p, max_tokens, max_completion_tokens, frequency_penalty, presence_penalty, seed, stop, logit_bias, reasoning_effort, response_format, tools, tool_choice, functions, function_call, n, logprobs, top_logprobs, modalities, audio, and web_search_options were read by nothing: a client that asked for JSON, three choices, or its own tool got none of them and no error. A value that asks for the default passes. /v1/responses already refused its own setting fields. POST /v1/conversations refuses initial items, which it used to drop.

  • Agents inside run recording receive a plain AgentRunOptions. An IAgentDecorator with an Order above 0 no longer sees TraconRunOptions; read the run through TraconRunContext. Microsoft Agent Framework’s function-invocation middleware (AIAgentBuilder.Use with a FunctionInvocationContext callback) rejects any other options type, so it threw NotSupportedException on every run started by a Tracon endpoint. It now runs.

  • /v1/chat/completions refuses tool-call history and unknown roles with 400. A tool or function message, an assistant message with tool_calls or function_call, and a role other than system, developer, user or assistant used to pass: the assistant turn was dropped, the tool result went on as plain text without its call id, and an unknown role became user. Tracon runs the agent’s tools on the server, and /v1/responses refuses function_call items the same way. A content part Tracon cannot hand to the model is refused by name, too.

  • AddSpeechSynthesizer(name, null) and AddSpeechTranscriber(name, null) with a literal null no longer compile: the call is ambiguous between the factory and the binding overloads. Cast the null, or pass the factory.

  • A harness agent that names skills and also calls other agents lists the skill tools after the background-agent tools, and puts the skill section of its instructions after the background-agents section. They were in the other order. A plain agent, and a harness agent that uses only one of the two, does not change.

  • A run’s events and its end are stamped with the TimeProvider its start came from. A run’s start came from the registered TimeProvider and its end and events from the system clock. With your own TimeProvider registered (a test clock, for example) a run seemed to last from the fake start to today, and the time a workflow continuation inherited from the run it continues was that gap. With the default clock nothing changes.

  • A version number of a stored agent could name another content after the agent was deleted and created again. The numbering restarted at 1, so a pin to version 3 (a workflow step’s agentVersion, a loop reviewer, an experiment arm) or a tool approval that waited on version 2 reached the new agent’s third or second save a few edits later, and ran its text instead of failing. A run on an approval, for example, was resumed with the new agent’s instructions and completed. The numbering now continues, so the old number names no version and the run stops before any step with pins agent 'x' to version N, but the agent has no version N (a workflow) or fails (an approval resume). Version numbers a run recorded before the delete are not reachable any more, as they were not before. Needs the migration of the Changed entry above.

  • A data subject erasure (DELETE /api/data-subjects/{id}?dryRun=false) did not return on SQLite. The audit entry is written before the erasure commits, from inside its transaction. On SQLite that transaction holds the database’s single write lock, so the audit write from a second connection waited until its command timed out, and the audit log retried the wait up to 50 times. On SQLite the audit entry is now written on the erasure’s own transaction, and the two commit or roll back together. PostgreSQL and SQL Server are not changed. An IAuditLog you register in place of Tracon’s own still writes on its own connection.

  • OpenAI and Azure OpenAI never received the response schema as written, and nothing showed it. The Microsoft.Extensions.AI client rewrites the schema on every request, strict or not: it closes every object, lists every property in required (an optional property becomes required and its type does not gain null), and moves 22 keywords (maxLength, pattern, minimum, minItems and others) out of the schema into the property’s description. An optional nickname the model did not know came back as "". The behavior is not changed; the validation report now shows it (see above).

  • The A2A and MCP exposure check refuses an agent whose skill tools ask for approval. The check read only the tool registry, and the skill tools are not in it, so an agent with skills was exposed and its first load_skill call closed the run Failed with approval_unanswerable (-32603 over A2A). The host now refuses the agent when it starts, names the agent and the way out (Skills.DisableReadApproval, or remove the skills), and opens it when the definition turns the read approval off. A host that exposed such an agent and relied on every call failing must set Skills.DisableReadApproval on the definition, or take the agent off the allow list. The check reads the catalog once, when the host starts: an agent saved later is not checked, and a model that calls run_skill_script still ends the run at the call unless a standing approval rule covers it.

  • A tool that Microsoft Agent Framework already wrapped for approval reports that it needs approval. AddTool(new ApprovalRequiredAIFunction(fn)) — the default shape of DockerShellExecutor.AsAIFunction() — asked for approval at run time, but /api/tools said requiresApproval: false. The A2A and MCP exposure check read that flag, so such an agent was exposed and every call failed with -32603. The startup check now refuses it, and replay sees the approval.

  • The .NET client reads a nullable enum property. ToolDescriptor.HostedKind, RunComparisonSide.ErrorClass, RunError.Class, WorkflowDescriptor.Kind, EvalCase.SourceKind and VoiceSession.EndReason were generated as an empty class named after the property in place of the enum, so the client threw JsonException when the host sent a value (The JSON value could not be converted to ...ErrorClass). They are the enum types now. A property the host always sends (WorkflowDefinition.Kind, WorkflowSaveRequest.Kind, RunErrorStatistics.Class) is no longer nullable in the client. The TypeScript client was not affected.

  • A stored definition written before a list member existed no longer breaks the catalog. The SQL payload read mcpResourceUris, parameters, and the new extensionNames as null when a row had no such key, and the catalog listing failed with ArgumentNullException for every agent. The three lists now read as empty.

  • A harness agent completes the turn that answers a pending approval. The approved (or rejected) tool ran, and then the next model call failed with InvalidOperationException: the harness reloads the session history on every model call, and the history still held the approval request while the response had been consumed. A plain agent was not affected.

  • UseOpenAIImages and UseAzureOpenAIImages publish with Native AOT. The image generator now runs on the shared client’s pipeline instead of ImageClient, whose members name a System.ClientModel type that version 1.16 renamed; ilc stopped with “Failed to load type”.

  • /v1/chat/completions hands image, audio and file parts to the model. It kept only the text parts, so the model answered as if the user had sent no image, and a message with only an image answered 400 “‘messages’ cannot be empty.” Embedded data passes the attachment type check and is stored as an attachment, as on /v1/responses.

  • An image’s detail and a file’s name now reach the model on /v1/responses and on POST /api/agents/{name}/run attachments. The attachment reference dropped both.

The public API gets smaller before it freezes, and every dependency moves to its newest line. Twelve interfaces, 23 types that only they used, 81 HTTP request and response types and TraconAgentSessionStore leave the public surface; five service types lose their public constructors; AuthorizationConfigurationKey is removed from the MCP server types, and a migration moves the stored data. The runtime moves to Microsoft Agent Framework 1.25.0 and Microsoft.Extensions.AI 10.11.0. Tracon.Azure calls the Azure OpenAI v1 API, Anthropic ReasoningEffort becomes adaptive thinking, and a configuration value Tracon cannot read now stops startup. The release adds one migration to each SQL provider and breaks source in several places: read Upgrading from 1.0.0-preview.4 first.

  • Migrations. PostgreSQL 0062, SQL Server 0050 and SQLite 0049 (remove_authorization_configuration_key). With AutoApplyMigrations (the default) they run at startup. Each one moves the stored authorization_configuration_key of an MCP server into its header_configuration_keys map as the Authorization entry, then drops the column. A row with OAuth on, or whose map already names Authorization in any letter case, keeps its map: the connection never read the old key there. An empty key moves nothing. Only the key name moves; no secret value is stored or touched.
  • The column is dropped in the same migration, so there is no way back. A build from before the migration cannot read the table. Back up the database before the first start on this version.
  • Every MCP connection reconnects once after the upgrade (its definition fingerprint changed).
  • Public types changed or left the surface. Each entry below (Changed, Removed) says what to do:
    • McpServerDefinition.AuthorizationConfigurationKey and McpServerRequest.AuthorizationConfigurationKey are removed; declare the key name as HeaderConfigurationKeys["Authorization"]. PUT /api/mcp-servers/{name} answers 400 to a body that still carries authorizationConfigurationKey.
    • Twelve interfaces, 23 types, 81 HTTP request and response types and TraconAgentSessionStore are internal. A host that named one declares its own type, or resolves a service from DI.
    • IRunStore.UpdateRunCostAsync has no default for tenantId; pass the run’s tenant.
    • SingleTenantContext, HttpTenantContext, TimeoutAIFunction, CompiledAgentCache and DefaultRunAttributionContext have no public constructor; resolve them from DI.
    • OpenAILiveOptions.BackendModel, LiveVoiceSessionOptions.BackendModel, QuotaUsageQuery.AsOf and LiveVoiceDelegationMode.Responses are gone.
    • Tracon.Client: IMcpToolRefresher and the body parameter of TraconRefreshMcpToolsAsync are gone.
    • JobStoreContract gains a test; a third-party IJobStore that keeps a time limit in a 32-bit millisecond column fails it.
  • Behavior changes most likely to reach an upgrade, each described in its entry below: a Tracon:Voice:Live:DelegationMode or Tracon:UtilityModel value Tracon cannot read stops startup; Tracon.Azure uses the Azure OpenAI v1 API and refuses an Entra token over plain HTTP; Anthropic ReasoningEffort is adaptive thinking; Tracon.Anthropic and Tracon.Google no longer promise Native AOT compatibility.
  • A project that pins Agent Framework hosting packages next to Tracon moves them to the versions in the dependencies entry (Changed), or restore fails with NU1608 or NU1107.
  • Pin every Tracon package to the same exact version. The pre-release Microsoft Agent Framework hosting packages are pinned at 1.25.0-preview.261014.1, Microsoft.Agents.AI.Hosting.OpenAI at 1.25.0-alpha.261014.1 and A2A.AspNetCore at 1.0.0-preview3.
  • Tracon.Azure calls the Azure OpenAI v1 API. Requests go to {endpoint}/openai/v1/ through the plain OpenAI client, with the deployment name in the request’s model field and no api-version query string; Azure.AI.OpenAI is no longer a dependency (its stable line stopped at 2.1.0 in December 2024). The resource must support the v1 API. UseAzureOpenAI, AzureOpenAIProviderOptions and the registered provider name do not change; an address that already ends in /openai/v1 is used as it is. An API key is sent as Authorization: Bearer. An Entra credential now goes only over HTTPS: a CredentialFactory with a plain http:// address is refused at the first call instead of sending the token. The health check calls GET /openai/v1/models, sends the key as Authorization: Bearer like the chat call (it sent an api-key header), and reports unhealthy instead of sending an Entra token over plain HTTP. Image generation uses the same v1 endpoint (/openai/v1/images/generations). The Azure-only request fields (On Your Data) never worked with the OpenAI version in use and have no counterpart on the plain client; SupportedSettings stays empty.

  • ModelBinding.ReasoningEffort becomes adaptive thinking on Anthropic. The request now carries thinking: {"type":"adaptive"} and output_config.effort (low, medium, high, xhigh). The budget form Tracon sent before (thinking.type = enabled with budget_tokens) is rejected with 400 by every current Claude model; the adaptive form is accepted by all of them (measured on the Claude 5 family). A model that predates adaptive thinking, claude-haiku-4-5, is the reverse: set anthropic.thinking.mode = enabled and anthropic.thinking.budgetTokens for it. A binding that names no ReasoningEffort sends the same request as before.

  • Tracon.Anthropic and Tracon.Google no longer promise Native AOT compatibility. The promise is now a measured result: a Native AOT app that calls the provider through its real request and response path must publish with no trim or AOT warning and run. Tracon.OpenAI, Tracon.Azure and Tracon.Voice pass. The Anthropic SDK’s chat adapter fails on the first request under Native AOT, and the Gemini credential library publishes with about a hundred warnings. The packages’ own code is unchanged and stays analyzer-clean. The release rehearsal now publishes and runs a provider smoke, and fails when the compatibility page promises AOT for a provider package the smoke does not reference.

  • IRunStore.UpdateRunCostAsync no longer defaults tenantId to null. The parameter stays string?; a caller now states the tenant on purpose. null skipped the tenant check, so a caller that left the argument out silently wrote a cost onto another tenant’s run. A call UpdateRunCostAsync(runId, cost) stops compiling: pass the run’s tenant. A store implementation drops = null from its own signature; the call shape does not change otherwise. The shipped RunStoreContract passes the tenant explicitly.

  • Five service types keep a public type and lose the public constructor. The container, or the tool pipeline, builds them, and the guides and samples never call new on them: SingleTenantContext, HttpTenantContext, TimeoutAIFunction (both constructors), CompiledAgentCache and DefaultRunAttributionContext. Resolve them from DI. Their registrations are factories now; TryAdd and Replace behave as before, so an ITenantContext registered before AddTracon() still wins. A new test pins every public constructor of a concrete class, so the next service type cannot reach the surface unnoticed.

  • Tracon:Voice:Live:DelegationMode refuses a name that is not a delegation mode. The host stops at startup with a message that names the setting. A misspelled name used to fall back to Client without a word.

  • Tracon:UtilityModel refuses a number it cannot read. Temperature, TopP and MaxOutputTokens that do not parse ("abc", "1.5" for the token limit, "0,7") stop the host at startup with a message that names the key and the text as written. Such a value used to bind as 0, so a typo ran the context-compaction summarizer at temperature zero without a word. An empty or blank value still means “not set”: the provider default applies. Code that assigns TraconOptions.UtilityModel after binding still wins.

  • Tracon.Client is regenerated with NSwag 14.7.1. Its public surface does not change. The client now passes the cancellation token when it reads the body of an unexpected response, so a cancelled call no longer waits for that body.

  • Tracon.Testing.Contracts.Xunit keeps asking for xunit.v3.extensibility.core and xunit.v3.assert >= 3.2.2. The package was measured on xunit.v3 4.0.2 with Microsoft.Testing.Extensions.TrxReport 2.5.1; a project on either line restores without NU1608.

  • Runtime dependencies move to the newest lines. Microsoft Agent Framework 1.25.0 (the hosting packages 1.25.0-preview.261014.1, Microsoft.Agents.AI.Hosting.OpenAI 1.25.0-alpha.261014.1, A2A.AspNetCore 1.0.0-preview3, all still exact pins), Microsoft.Extensions.AI 10.11.0, OpenAI 2.14.0, the .NET platform packages 10.0.13, Anthropic 12.58.0, Google.GenAI 1.26.1, Azure.Core 1.63.0 and Microsoft.Data.SqlClient 7.1.1. A project that pins Agent Framework hosting packages next to Tracon now gets NU1608 or NU1107 at restore, as before; move it to these versions. Behavior does not change: the Anthropic provider still sends ReasoningEffort as an extended-thinking budget, although the SDK’s own default is now adaptive thinking. Microsoft.Bcl.AsyncInterfaces, Microsoft.Bcl.HashCode and Microsoft.Bcl.Memory leave the dependency graph of Tracon.Core (and so of Tracon and Tracon.Google): the token-counting data package now ships a net8.0 build.

  • The console build tools move to their newest lines; building the console needs Node.js 22.22.2 or later on the 22 line, 24.15 or later on the 24 line, or 26 and above. TypeScript 7, Vite 8 (Rolldown), Vitest 5, jsdom 30 and React 19.3.1. The embedded console grows from 211.9 KB to 215.4 KB gzip (budget 250 KB), and the embeddable widget (embed/embed.js) still runs in strict mode: Vite 8 dropped the "use strict" directive and the build now asks for it. The embedded file set, the public API and the HTTP contract do not change. A contributor on Node.js 20 now gets an npm ci engine error that names the required version, not a wall of failing tests. The NuGet packages are not affected: a consumer does not build the console.

  • The compiled dist/ of @tracon/client is byte for byte the same, in the same 23 files. Only the development dependencies in its package.json move (Vitest 5, @types/node 26.6); a consumer installs the same code and the same runtime dependency. It still builds with TypeScript 5.9, because openapi-typescript 7 needs the TypeScript 5 compiler API that TypeScript 7 does not ship.

  • McpServerDefinition.AuthorizationConfigurationKey and McpServerRequest.AuthorizationConfigurationKey are gone (authorizationConfigurationKey on the wire; deprecated since 1.0.0-preview.3). The Authorization header is declared like any other credential header: HeaderConfigurationKeys["Authorization"] (headerConfigurationKeys on the wire). Code that sets or reads the member stops compiling. The same members leave the generated Tracon.Client types McpServerDefinition and McpServerRequest and the @tracon/client schema, and the field leaves both HTTP schemas. A third-party IMcpServerStore that persisted the field drops it and must keep HeaderConfigurationKeys (the shipped McpServerStoreContract gains Every_field_of_a_server_survives_a_round_trip). PUT /api/mcp-servers/{name} refuses a body that still carries authorizationConfigurationKey, with any value and in any letter case, with 400 (title Removed field) and a detail that names headerConfigurationKeys["Authorization"]. Without the rule the field would be dropped silently and the first tool call would fail with 401. Other unknown fields are still ignored. The console form writes the key name into headerConfigurationKeys.Authorization; an empty input sends no map, so a stored map is kept. The migration (see Upgrading) moves the stored data first.

  • Twelve interfaces and the 23 types that only they used are internal. They are plumbing between Tracon’s own packages. No guide, sample or contract suite asks a consumer to implement one, and the shipped implementations are the only ones. IToolRegistry, IVoiceHealthCheck and IWorkflowRunner stay public: a guide names the first two, and IWorkflowRunner is the one public way to run a workflow in process. A host that registered its own implementation of one of these interfaces, or named one of the types, stops compiling. The types, by package:

    • Tracon.Abstractions (34): IAuditActorResolver, IAuditDecorated, IMcpOAuthCoordinator, IMcpPromptClient, IMcpResourceClient, IMcpResourceContextProviderFactory, IMcpToolRefresher, IStatePreflightReader, ITraconDrainState, IWebhookPublisher, IWorkflowFunctionCatalog, McpOAuthCompleteResult, McpOAuthOperationStatus, McpOAuthStartResult, McpOperationStatus, McpPromptArgumentSummary, McpPromptContent, McpPromptListResult, McpPromptSummary, McpRefreshOutcome, McpResourceContent, McpResourceListResult, McpResourceSummary, StateGenerationTally, StatePreflightTarget, StateSample, WebhookApprovalSummary, WebhookEvalSummary, WebhookEventPayload, WebhookJobSummary, WebhookQuotaSummary, WebhookRunSummary, WebhookScoreSummary, WorkflowFunctionDescriptor.
    • Tracon.AspNetCore (1): ITraconUiProvider.
  • TraconAgentSessionStore is internal. Its base type, AgentSessionStore, has been [Experimental] since Agent Framework 1.22, and a public type cannot freeze a base its owner may still change. /v1/responses keeps using it unless the host registers its own AgentSessionStore; that registration always wins. No guide or sample built the class. A host that did, to wrap it in IsolationKeyScopedAgentSessionStore, supplies its own store instead. A store the host supplies replaces the Tracon-backed one for /v1/responses entirely: the tenant filter, the session ownership check and the sessions API of Tracon do not see those conversations. The store keeps session identities as before: the isolation partition of a key is folded into the identity as <escaped key>::<session id>, and a partition it does not know is refused.

  • Four members that never worked or never did anything are gone. OpenAILiveOptions.BackendModel and LiveVoiceSessionOptions.BackendModel were read by nothing. QuotaUsageQuery.AsOf was [Obsolete] since 1.0.0-preview.3 and no store applied it; use PeriodStarts. LiveVoiceDelegationMode.Responses made every session that chose it fail at the provider with a 502; the enum keeps Client, the only mode. A BackendModel key left in configuration is ignored and the host starts.

  • 81 HTTP request and response types are internal. They were public only so that the OpenAPI document could carry their descriptions. A .NET client does not use them: Tracon.Client generates its own types, and the HTTP contract does not change. Paths, methods, schema names, required, property types and $ref are identical, and the document keeps every description (see Fixed for the five that changed). A host that referenced one of these types declares its own record for the body or uses the generated client type of the same name. The descriptions now come from [Description], so a new HTTP type must carry it; a test fails the build when a schema or property loses its description. The types, by package:

    • Tracon.Abstractions (17): AgentValidationReport, CanaryDecisionKind, CanaryEvaluation, ContextWindowEstimate, DataSubjectErasureResult, ImageGenerationOperatorRequest, ImageGenerationOperatorResponse, JudgeFailure, JudgeRunResponse, RetentionPreview, RunCostRecalculationResult, SessionBranchRequest, SessionBranchResult, SpeakRequest, SpeakResponse, ValidationMessage, ValidationSeverity.
    • Tracon.AspNetCore (63): AcceptedRunResponse, AgentDetailResponse, AgentRollbackRequest, AgentSkillRequest, AgentVersionDiffResponse, ApiKeyCreateRequest, ApprovalDecisionRequest, CurrentTenantResponse, EvalCaseInput, EvalCasePromotionRequest, EvalRunDetailResponse, EvalRunTriggerRequest, EvalSuiteSaveRequest, ExperimentCanaryResponse, ExperimentResultsResponse, ExperimentSaveRequest, InboundTriggerAcceptedResponse, InboundTriggerResponse, InboundTriggerSaveRequest, JobDetailResponse, JobScheduleSaveRequest, JobTriggerRequest, LiveVoiceSessionCreateRequest, LiveVoiceSessionCreateResponse, LiveVoiceSessionStatusResponse, LoopCriterionCatalogResponse, LoopCriterionKindResponse, LoopJudgeModelResponse, McpOAuthStartResponse, McpPromptArgumentsRequest, McpRefreshResponse, QuotaSaveRequest, QuotaUsageResponse, RetentionPolicySaveRequest, RetentionRunTriggerResponse, RunComparisonResponse, RunComparisonSide, RunFeedbackRequest, RunInputResponse, RunReplayResponse, SearchKnowledgeHit, SessionDetailResponse, SkillScriptGrantRequest, TenantEgressPolicyRequest, TenantEgressPolicyResponse, TenantProviderBindingRequest, TenantProviderBindingResponse, TenantRequest, ToolApprovalRuleRequest, TraconAuthenticationMeta, TraconMetaResponse, TraconRoleMeta, TraconStorageMeta, UploadDocumentResponse, WebhookSaveRequest, WebhookTestResponse, WorkflowFunctionResponse, WorkflowRespondHttpRequest, WorkflowResumeHttpRequest, WorkflowRollbackRequest, WorkflowRunHttpRequest, WorkflowSaveRequest, WorkflowVersionDiffResponse.
    • Tracon.Core (1): OnlineEvaluationSummary.
  • Tracon.Client.Generated.IMcpToolRefresher and the body parameter of TraconApiClient.TraconRefreshMcpToolsAsync. The class was never a request body: it was the server’s own refresher service, published by mistake as a schema. A call that passed null or nothing as the body drops the argument; TraconRefreshMcpToolsAsync(cancellationToken) sends the same request.

  • WorkflowRunRequest.DefinitionVersion. The version of a stored workflow’s definition to run, the same rule as WorkflowResumeRequest.DefinitionVersion: the run compiles that version and records it on its row. null (the default) runs the current definition; a version the workflow does not have fails the run and never falls back; a workflow defined in code ignores the value. The HTTP API does not set it.
  • JobStoreContract checks a job time limit beyond 24.8 days. A_jobs_time_limit_beyond_an_int_of_milliseconds_survives_the_round_trip_and_the_lease writes a 30-day limit and reads it back from the enqueue, the read and the lease. A third-party IJobStore that keeps the limit in a 32-bit millisecond column now fails the contract.
  • A queued workflow run that recovery starts again, because the interrupted attempt wrote no checkpoint, now compiles the definition version that attempt recorded. It compiled the current definition, so a save made while the job waited changed what ran in the same session. Batch items restart the same way.

  • Cancelling a run at the moment it finishes no longer fails with ObjectDisposedException. POST /api/runs/{id}/cancel answers 409 instead of 500, and cancelling a run tree still reaches every sibling. Closing a live voice session while a delegation finishes no longer ends the close with the same exception.

  • UseLiveVoice(options => options.DelegationMode = …) with a value outside the enum stops the host at startup. It reached the provider on the first session and ended as 500.

  • Anthropic prompt caching with anthropic.promptCaching.placement = system keeps the cached instructions in the top-level system array under Anthropic SDK 12.58. The SDK adapter now moves only the system messages in front of the first user or assistant message there; the provider appended the instructions block after the conversation, so it went out as a role: system entry inside messages and the cache breakpoint sat in the conversation. The block is now placed in front of the first non-system message.

  • Five property descriptions in the OpenAPI document no longer carry an unresolved XML reference. WorkflowSaveRequest.nodes read IReadOnlyList&lt;string&gt;? WorkflowSaveRequest.AgentNames where the text means AgentNames; DataSubjectErasureResult.dryRun, JobScheduleSaveRequest.lane, InboundTriggerSaveRequest.payloadPath and InboundTriggerResponse.payloadPath had the same fault. @tracon/client and Tracon.Client carry the corrected text.

  • POST /api/mcp-servers/refresh no longer declares a request body. The endpoint took its refresher service without [FromServices]; in a host without Tracon.Mcp the framework read the service from the body instead, so the OpenAPI document, @tracon/client and Tracon.Client carried a body schema (IMcpToolRefresher) for four preview releases. The request itself is unchanged. A test now fails the build when any endpoint reads a service interface from its body.

Workflows become a control plane for long-running pipelines that a person reviews. A run can be queued and resumes after a restart; a waiting request has one answer, an inbox and a time limit; every step has a time limit, cancellation, a cost record and a budget gate; and a graph written in code gets the same node capabilities as a saved chain, with a revision loop that is safe on a back edge. Speech gains multi-speaker dialogue and named providers chosen per tenant. The release changes the public API and adds seven migrations to each SQL provider: read Upgrading from 1.0.0-preview.3 first.

  • Migrations. PostgreSQL 0055–0061, SQL Server 0043–0049 and SQLite 0042–0048. With AutoApplyMigrations (the default) they run at startup.
  • Five public types changed. Each entry below says what to do:
    • IRunStore gains RecordNonTokenUsageAsync and ListNonTokenUsageAsync; a custom store adds both (Changed).
    • IWorkflowDefinitionStore gains GetVersionAsync, ListVersionsAsync and RollbackAsync; a custom store keeps the version history (Changed).
    • IWorkflowFunctionCatalog gains ValidateSettings (Changed).
    • TraconApiClient: three methods gain optional parameters; a call that passes skip, take or the cancellation token by position names the argument.
    • ModelProviderSettingsContract.SupportedSetting is now virtual and nullable; an existing override adds ? to its return type.
  • One source break: AddWorkflowFunction<TInput, TOutput>(name, factory, null) is ambiguous (CS0121); pass a description or use the overload that takes options.
  • Behavior changes most likely to reach an upgrade, each described in its entry below: a configuration value Tracon cannot read stops startup; answering a workflow request is audited before it runs, and an audit store that cannot write refuses the answer with 500; knowledge-base embedding applies the tenant’s egress policy; every run of a tree adds its spend to the quota; a harness-loop agent hands only its final iteration to a workflow step, the OpenAI-compatible and A2A streams and voice; functions and requests are reserved workflow names.
  • Pin every Tracon package to the same exact version. The pre-release Microsoft Agent Framework hosting packages stay pinned at their 1.20.0 previews.
  • The native agent stream (POST /api/agents/{name}/run) streams every harness-loop iteration, the loop’s feedback included, with no boundary between iterations. A client that shows that stream as the answer shows the drafts. Read the run’s loop.iteration-completed events, or use a surface that returns the final iteration (the non-streaming run, the OpenAI-compatible endpoints, a workflow step).
  • In a graph written in code, an agent step’s version pin is fixed when the graph is built, and the graph is built for every run; there is no pin per production. A revision that runs after a new agent version is saved uses that version.
  • Answering a workflow’s pending human request is now the seventh fail-closed audit operation. Every POST /api/workflows/runs/{runId}/respond that starts a run writes workflow.respond (the source run, the request id, the run the answer starts, the response mode and a SHA-256 of the answer — never the answer itself) before the run starts, queued or streamed, in every response mode. Behavior change: when the audit store cannot write the entry, the answer is refused with 500 and no run, record or job is created; before, an answer was never audited. IAuditLog’s documented set grows from six operations to seven. The hash tells two answers apart; it does not hide an answer with few possible values, such as a yes or no.

  • A refused provider setting no longer repeats its value. The Anthropic choice settings (anthropic.effort, anthropic.thinking.mode, the prompt-caching placement and TTL) and thinking budget, and the Google safety thresholds and thinking budget, wrote the rejected value into the message, which reaches logs and run records; a value can be a credential pasted into the wrong field. The message now names the key and the accepted values only.

  • The built-in ElevenLabs client refuses a tenant credential with NotSupportedException on synthesis, streaming synthesis, dialogue and transcription. It does not take a tenant key; a caller that passed one directly was served on the configured key, so the host’s account paid for the tenant’s speech. The resolver never sends one, so calls made through Tracon are not affected.

  • Knowledge base embedding calls now apply the tenant’s provider checks before the tenant’s text leaves the process. A document upload, POST /api/knowledge/{collection}/search and the search_knowledge tool called the registered embedding generator and read neither the tenant’s egress policy nor its own provider key. Name the provider in the new TraconKnowledgeOptions.EmbeddingProvider (Tracon:Knowledge:EmbeddingProvider); without it, the name the generator reports is used. Behavior change: a tenant with an egress policy that does not name the embedding provider, or with its own key for it, gets 403 on upload and search (new in the OpenAPI document and both generated clients) and a failed tool call. When no provider name is known, a tenant with a policy is refused; a tenant without one is not affected.

  • A data subject export now returns protected columns as plaintext. With AddContentProtection(...) on, the export wrote the stored envelope of session state, run inputs and chat history, so the subject received ciphertext.

  • Workflow checkpoint state is now a protected column (ProtectedColumn.WorkflowCheckpointState). It carries a workflow run’s messages and was stored in the clear even with content protection on. New checkpoints are encrypted when the column is in Columns (the default takes every column); existing rows stay readable. No migration.

  • Data subject export and erasure now reach four more copies of a subject’s run: an eval case promoted from the run, the stored response of an idempotent request that started it, the job of a queued run (its id is the run id) and the batch item that started it. Behavior change: erasing a subject now deletes those rows, so an eval suite loses the cases promoted from the subject’s runs; a batch job keeps its other items. The erasure’s audit entry counts each target.

  • A refused configuration key name is no longer echoed. An operator who typed a value, such as an API key, into a field that takes a configuration key name got that value back in the 400 body, and a webhook delivery wrote it to webhook_deliveries.error. The message now names the field and the allowed key space only. This covers every field that takes a key name: MCP server headers and OAuth secret, tenant provider bindings, webhook headers and signing secret, and inbound trigger secrets.

  • A retention archive now hands protected columns to the IArchiveSink as plaintext. With AddContentProtection(...) on, the archive wrote the stored envelope of run event text and payload, tool call arguments and results, workflow checkpoint state, attachment content, session state and run inputs. Behavior change: a sink that stored those rows now receives the readable content; encrypt it in the sink if the archive must be encrypted at rest.

  • A real data subject erasure now refuses with 409 while one of the subject’s runs is Queued or Running. Such a run could write its session and workflow checkpoint back after the erasure committed. The problem carries the run ids in activeRunIds; a dry run lists them in the new DataSubjectErasureResult.ActiveRunIds and erases nothing. Cancel the runs, or pass force=true to erase anyway. 409 and force are new in the OpenAPI document and both generated clients.

  • Text-to-speech, speech-to-text and the voice list now apply the tenant’s provider checks before any text or audio leaves the process. The speak, transcribe and list_voices tools, POST /api/voice/speak, GET /api/voice/voices and the conversation socket called the setup-wide speech provider and read neither the tenant’s egress policy nor its own provider key. Behavior change: a tenant with a saved egress policy that does not name its speech provider (elevenlabs for the built-in client) can no longer synthesize, transcribe or list voices: the endpoints answer 403, the conversation handshake answers 403 before a connection slot is taken (an open conversation keeps the providers it was opened with until it closes), and a tool call fails. Add the provider name to the policy to keep speech working. A tenant with its own binding for the speech provider is refused unless the provider declares SupportsTenantCredentials; the built-in ElevenLabs client does not, so such a tenant is no longer billed to the host’s key. A binding with an endpoint override, or whose key has no value, is refused too. The policy is judged against the provider name the engine reports, never the name it is registered under.

  • Image generation now applies the tenant’s provider checks before the prompt leaves the process. generate_image and POST /api/images/generate called the setup-wide generator and read neither the tenant’s egress policy nor its own provider key: a tenant whose policy did not allow the image provider still had its prompts sent there, and a tenant with its own key for that provider was billed to the host’s account. A policy that does not name Images:Provider now refuses the call, and so does a tenant binding for that provider, because the generator is built with the setup key and cannot use the tenant’s. The endpoint answers 403 (new in the OpenAPI document and both generated clients); the tool call fails with the same message. A run without a tenant scope now fails before the provider call instead of after it.

  • A root agent run now counts its whole call tree in the tenant’s quota counters and in its run.completed/run.failed webhook. A child agent run never accounts on its own, and the root counted only its own tokens and cost, so the children’s spend reached no counter and a token or cost quota was exceeded through sub-agents. The root now adds what its child runs recorded; a workflow root already counted its tree. The webhook’s inputTokens, outputTokens and cost of an agent root are now the tree’s totals, the same meaning a workflow root’s event has. Costs in two currencies are not added; the root’s own is kept.

  • A tenant endpoint on a provider added with UseOpenAICompatible() now goes through the outbound address guard, as on every other provider. The factory built the provider without the guard, so a tenant binding whose endpoint host name resolved to a private or metadata address received the tenant’s prompts and key; the binding save path checks only IP literals.

  • One tenant can no longer stop a provider for every other tenant on the shared setup-time key. The circuit breaker counted every exception, so a tenant’s repeated 400s, an exhausted run budget, a history that names a deleted attachment, or a tool failure that the tool-call loop rethrows opened the provider’s circuit for everyone for the break duration. It now counts only a failure the provider client threw that shows the provider is unhealthy: no connection, a timeout, a 5xx, 408 or 429, or a 401/403, because a rejected key fails every caller in the same way. Any other permanent 4xx does not count, and a failure that does not count leaves the counter and a half-open circuit as they were. A test or runbook that opens the circuit with a 400 or a 404 now needs a 401 or a connection failure.

  • Data subject export and erasure now include workflow checkpoints and the scores of the runs found through a subject’s sessions. Neither table has a foreign key to runs, so nothing cascaded to them: an erasure left the checkpoint state, which can hold the subject’s own messages, and the scores with their comments, and the export did not show them. A checkpoint’s state is exported as the stored JSON text. Erasure also removes the checkpoints of a run that waits for input: retention keeps them, erasure does not.

  • A job that resumes an approved tool call (ApprovalResume, a built-in key a schedule can also enqueue) now fails when its payload names a different agent than the run that asked for approval, or when that run no longer exists. A hand-written payload could run another agent with the approved tool call.

  • A failed workflow node no longer writes its exception’s own message to the run’s events. The run row already carried only the exception’s type and a correlation id, but the ExecutorFailed event of the same failure carried the raw message - a host and port, a request detail - to the event table, the SSE stream and every run event sink. The event now carries the same display-safe text as the row (<Type> failed. (ref: <id>), or a Tracon exception’s own message), and both name the same log entry.

  • A harness agent no longer reads skills from, or writes memory files to, the server’s working directory. Microsoft Agent Framework’s harness falls back to the process’s working directory when it is not given a source: with no skill source it loaded every SKILL.md it found there, past the agent’s SkillNames, the skill’s enabled flag and the tenant’s scope; with no file store it wrote what the model chose under ./agent-file-memory, outside the tenant stores, retention and erasure. Every harness agent got both, including one saved from the console. Skills are now offered only when the definition names them, and file memory only when Memory.EnableFileMemory is true, on the registered AgentFileStore. A definition that names skills while Harness.DisableAgentSkillsProvider is true is refused when it is compiled; its skills were dropped without a word.

  • A tenant’s egress policy and its own provider key now reach every model an agent calls, not only the agent’s own model. The model that summarizes the conversation during compaction (Compaction.SummarizationModel, or UtilityModel when the agent names none) and the judge of an aiJudge loop criterion were built without either. A definition that named a provider the tenant may not call compiled and ran, and each compaction sent the tenant’s conversation to that provider. Such a definition is now refused when it is compiled or validated, and the refusal names the provider. The summarizer uses the tenant’s own key when the tenant has one for its provider, so the tenant pays for it, and such an agent is not cached: a rotated or deleted key takes effect on the next run. The loop judge keeps the setup-time key: it is the host’s one judge model, the same one the run judge calls.

  • A saved egress policy now reaches agents compiled before it. A compiled agent kept the policy decision of the moment it was compiled, so after a tenant forbade a provider, an agent already in memory kept calling that provider until its definition changed or the process restarted. The next run is now compiled under the saved policy and refused before any request leaves; a run already in progress is not stopped. Each run reads the tenant’s policy once to build the cache key.

  • A standing approval rule for a tool of an MCP server is now bound to the server’s endpoint. An MCP tool is named {server}_{tool}, so when a key with the AgentsAdmin scope pointed a server at another endpoint (or deleted it and added it back under the same name), a rule a security administrator wrote for the old endpoint approved the new endpoint’s tool. A rule now records a fingerprint of the endpoint (sourceFingerprint, a SHA-256, never the address) when it is written, by an administrator or by the “don’t ask again” flow, and approves the tool only while the server keeps that endpoint. While a server’s tools are not discovered (its last refresh failed), its tools always ask, and POST /api/approvals/rules for one of them answers 409. Rules stored before this release carry no fingerprint: each standing rule for an MCP tool asks once more after the upgrade. ToolApprovalRule.SourceFingerprint is new and part of a rule’s identity; a custom IToolApprovalRuleStore has to store and compare it (see Changed).

  • Saving an MCP server with requiresApproval: false now requires the SecurityAdmin scope as well as AgentsAdmin when the call uses an API key: for a new server, for a server that required approval, and for a server whose endpoint changes while approval stays off. The flag waives approval for every tool of the server, which is the decision only SecurityAdmin could make for a single tool; an AgentsAdmin key alone could switch it off and answers 403 now.

  • A provider-hosted live voice session now obeys the tenant’s egress policy and its own provider key. The session is bound to a tenant, but the OpenAI live provider used the shared key and read no policy, so a tenant whose policy excluded openai still had its audio sent there, and a tenant with its own key was billed to the installation’s. POST /api/voice/live/sessions now answers 403 when the policy does not allow the live provider, when the tenant’s binding for it has no value, or when the provider cannot use the tenant’s key; a tenant with its own binding gets its own key on both the session creation and the sideband attach. A binding with an endpoint override is refused for a live session (403).

  • Tracon.SqlServer no longer restores a System.Security.Cryptography.Pkcs with known vulnerabilities (GHSA-846f-f764-76mx, high; GHSA-2fmv-rv2c-59x7, moderate). Microsoft.Data.SqlClient pulls 8.0.1 on net8.0 and 9.0.13 on net9.0 and net10.0, and every SqlClient release up to 7.1.1 does the same, so a new project that used --persistence sqlserver restored with NU1902 and NU1903. The package now depends on the patched version for each target framework (8.0.2, 9.0.21, 10.0.13), which wins over SqlClient’s own. It adds no further packages to the graph.

  • A workflow run that reaches its time limit no longer ends its stream with an exception. When the limit fell between the moment an event arrived and the moment it was recorded, recording it on the run’s cancelled token threw TaskCanceledException out of the stream: the run was closed Canceled, but a streaming caller got the exception instead of the closing event. The run now stops there as it does between two steps.

  • A job released for another attempt no longer loses that attempt. A failed attempt with attempts left makes the job leasable at once, and the worker could lease it again while the attempt that released it was still finishing on the same worker. It logged “already executing” and dropped the new lease, but the store had already counted the attempt: the job stayed Leased until the lease ran out and then failed as out of attempts without running again. The new lease now waits for the previous attempt and runs, unless that attempt finished the job.

  • A pending workflow request whose type is a record of the graph’s own is answered correctly. The run that answers reads the request back from a checkpoint, where its data is JSON, and the plan-review check asked the framework to read that JSON as a Magentic plan review, every member of which is optional: a record such as {"text": "...", "violation": null} was taken for one, and the answer was built the wrong response type (Message type MagenticPlanReviewResponse does not match expected response type). The check now reads the port’s declared request type. The run that published the request held the live object and never showed the defect.

  • A harness loop agent no longer hands its drafts to the caller that acts on its answer. A streaming loop yields every iteration, its drafts and its feedback included, and six surfaces read that stream as the answer: the next node of a workflow (a text-to-speech node spoke the rejected version), the next agent of a ready-made Sequential workflow, the streaming /v1/chat/completions and /v1/responses, the streaming A2A call, and the two voice surfaces, which spoke the drafts aloud. These surfaces now run a loop agent through the non-streaming path and stream its final iteration only; its usage stays the sum of every iteration. Behavior change: on these surfaces a loop agent’s answer arrives in one piece when the loop ends, not token by token. The native POST /api/agents/{name}/run stream is unchanged and still streams every iteration. A custom IAgentDecorator that does not delegate GetService hides the loop, and the agent then streams as before.

  • An approval that ran out of time no longer erases what the run that asked had recorded. The expiry closed the run Failed with a completion built from nothing, and IRunStore.CompleteRunAsync replaces the event count, usage and cost of the row with what the completion carries, so a run that had spent tokens before it paused lost its usage, its cost and its event count. The expiry now reads the run first, closes it only while it is still AwaitingApproval (a run something else closed keeps its outcome), and writes those values back.

  • Tracon:Retention:IdempotencyKeys is now bound from configuration. Its MaxAgeDays and Archive keys were ignored and the target kept its one-day default; only code (Configure<TraconRetentionOptions>) could change it. A configuration that sets them now takes effect, and MaxAgeDays below 1 fails startup like every other target.

  • /v1/chat/completions now reports why the answer ended. finish_reason was always "stop", so an OpenAI SDK caller could not tell a cut-off answer from a complete one. Behavior change: an answer the model stopped at its output token limit or at its context window is "length", and an answer a content filter cut off after it produced text is "content_filter", on the JSON response and on the last chunk of the stream (data: [DONE] still ends it). The value comes from the answer’s final model call; a tool turn’s reason is never reported, and run recording does not need to be on. A filtered answer with no content still fails with 502 (error on the stream), and a cut-off structured answer still fails as before.

  • A failed read of the quota threshold notice no longer fails the agent run’s stream. With TraconQuotaOptions.PublishThresholdToRunStream on, a run store error while the stream read the notice back ended the stream of a completed run with an error frame. Behavior change: the stream now logs a warning and ends with done, without the notice; the notice stays on GET /api/runs/{id}/events.

  • A tenant’s quota now counts what a call tree spends after its root run ended. The root read its tree from the run store once, while its own row closed, so a child agent still running in the background at that moment (a root that starts a task and answers without waiting) never reached a counter, and neither did the amount a tool reported after its call timed out and its run closed. Behavior change: every run of a tree now adds its own tokens and cost to the counter when it ends, under the root’s tenant and name (a child adds no run: the tree is still one run), and a tool amount that arrives after its run closed is added to the counter once, in Tracon:Pricing:Currency. The counter and GET /api/runs/{runId} give the exact total; the run.completed webhook is unchanged and shows the tree as it stands when the root closes. A threshold crossed by a child run is written to that child’s own event stream (with Quotas:PublishThresholdToRunStream; read it from GET /api/runs/{runId}/events) and its quota.threshold webhook names that run; a tool amount that crosses one is announced by the webhook only. A child priced in another currency than Tracon:Pricing:Currency adds its tokens and the amounts it reported beyond tokens, not its token cost. A store of your own needs no change; QuotaStoreContract has two cases for an entry that adds tokens and cost and no run.

  • A job no longer repeats a failure the next attempt would get again. The worker retried every handler failure up to the attempt limit: an eval whose judge was cut off at its output limit ran its unfinished case and paid for it on every attempt, and a queued run whose model answered a permanent 4xx ran again too. Behavior change: a failure whose stable type is output_truncated or tenant_provider_refused, or a model provider failure (upstream_error) with a 4xx other than 408 and 429, fails the job on that attempt. The decision reads the type, never the message; an exception a consumer handler throws itself keeps its retries, and so does JobRetryException. A workflow node retry and the online evaluation read the same decision.

  • A tenant’s refusal of a provider is no longer classified by its text. Its error type was the .NET type name, and a tenant or provider name with “timeout” or “429” in it made the refusal a transient fault that a workflow node retried. Behavior change: every refusal (model, fallback, summarizer, loop judge, image, speech, embeddings, live voice) is the new TraconTenantProviderRefusedException, error type tenant_provider_refused, with the new class RunErrorClass.TenantProviderRefused (in the OpenAPI document and both generated clients). A compilation no longer wraps it as TraconCompilationException (compilation_failed); the message still names the agent. A binding that names a configuration key the tenant may not read is a refusal too: the image, speech and knowledge endpoints answer 403 for it, not 500. The model path’s refusal of a tenant credential its provider cannot use was provider_credential_unsupported and is now tenant_provider_refused too. The image and live voice refusals name their kind of provider instead of “model provider”.

  • A provider failure masked as upstream_error that answered 429 is now RateLimited, not ProviderError, and a status .NET carries only on HttpRequestException.StatusCode now reaches the record ((HTTP 401)).

  • An Anthropic answer cut off by the context window (model_context_window_exceeded) and a turn the API paused (pause_turn) no longer complete the run as finished. The SDK’s adapter reported both as a plain stop. A cut-off structured answer now fails with output_truncated and a plain one writes tracon.output.truncated, both naming the context window; the event payload gains a cause field (output_limit or context_window). A paused turn is continued, its blocks sent back as the API returned them, up to five times; a turn still paused fails the call. New: TraconFinishReasons.ContextWindowExceeded, a finish reason any provider can report.

  • A SQL store write that the server picks as a deadlock victim is run again. The SQL Server upsert gave error 1205 to its caller under concurrent writes. Every store transaction now opens at one boundary that runs the whole transaction again, up to five times, on a new connection.

  • A failed webhook send whose result could not be recorded kept its retry delay. The store error used to replace the send’s failure: the job was released at once, and on the last attempt the delivery stayed Pending. It now ends Failed.

  • TraconToolUsage.Report refuses a measurement the record cannot hold, as a function node’s item is refused. An oversize unit, provider or model reached the insert, and on PostgreSQL and SQL Server the failed insert lost the call’s whole record.

  • GET /api/voice/voices answers 502 when the provider fails, as POST /api/voice/speak does; it answered 500 (new in the OpenAPI document and both generated clients).

  • The first function node of a workflow node list runs once per run. A run sends the first node the user’s message and then Microsoft Agent Framework’s turn token, on every start and resume, and a first node that takes object received both and ran twice — a node with an outside side effect did it twice. Behavior change: saving a node list whose first node takes object answers 400, and a stored one fails before its first node runs; declare List<ChatMessage> or IEnumerable<ChatMessage>.

  • No money total adds amounts in two currencies. A run tree, the totals and breakdowns of GET /api/stats, the points of GET /api/stats/timeseries, an experiment arm and the online evaluation’s judge cost added the runs’ amounts and labelled the sum with one of their currencies (the in-memory store the first, the SQL stores the alphabetically largest). When the runs are in more than one currency, the total now keeps no amount and no currency, and the new CurrencyCount says how many there are; letter case is not a second currency. RunSpend.Total for a tree returns null for such a tree, so the quota and the run.completed webhook no longer count a mixed sum: an agent root counts its own cost. The console shows mixed currencies.

  • A run whose caller can never answer its tool approval no longer waits forever. /v1/chat/completions answered 200 with empty content; it now answers 409 with the error type approval_required (or, on a stream, an error frame of that type and no [DONE]). POST /api/agents/{name}/run without a sessionId answered 200; it now answers 409 with errorType approval_unanswerable (or an error event instead of done). In both, and for an agent exposed over MCP or A2A, the run closes Failed with the error type approval_unanswerable; an MCP task poller still sees the refusal. The 409 responses are new in the OpenAPI document and both generated clients.

  • A queued turn that ends at a tool approval no longer calls the model again when the approval cannot be written. The write is tried again within the lease, and a write the store committed but reported as failed is not written twice; past the lease the job fails with tracon.job.approval-save-failed and the run closes Failed.

  • A structured-output agent (Json or JsonSchema with validation on) whose turn stops at a tool approval or at a client-side tool call no longer fails with “The response is empty.” Such a pause is not validated; the turn that carries the decision or the tool result is.

  • A workflow run stopped by a graceful shutdown keeps its checkpoints while ResumeInterruptedRuns is on, also with KeepCheckpointsAfterCompletion off. They were deleted when the stopped run closed, and its continuation ran every finished step again; the continuation now removes them when it finishes.

  • An agent can name a skill that exists only under a skill root (SkillRoots). The catalog and the definition check looked in code and in the store only, so such an agent failed with “the skill was not found” and no HTTP run reached a script on disk. A disk skill is installation-wide like a code skill; a stored skill under the same name shadows it for its tenant. An edit to its SKILL.md reaches the next run.

  • The Tracon:Knowledge configuration section is now read. TraconKnowledgeOptions was documented as bound from it, but every value there was ignored; only code that called Configure<TraconKnowledgeOptions> changed a setting. A Dimensions value in configuration now takes effect: it must match the migrated vector column.

  • A draining process (Drain.Enabled) now refuses every new run it would start itself, not only the agent run endpoint. The workflow run, resume and respond endpoints and run replay started a run during the drain; they now answer 503 (new in the OpenAPI document and both generated clients). /v1/chat/completions and /v1/responses answer 503 in the OpenAI error shape with type service_unavailable, the voice conversation handshake answers 503 before the upgrade, an MCP tools/call returns an error result and an A2A message fails. A trigger, eval, schedule or approval still writes its job, which waits for a worker that is not draining.

  • A provider error with an empty or non-OpenAI body is now read with its HTTP status. System.ClientModel (OpenAI, Azure OpenAI) writes Status: 404, the Anthropic SDK Status Code: NotFound and Google.GenAI status code 404; only HTTP 404 was read. Such a 401, 403 or 404 moved to the next fallback link, and such a 400 or 404 counted against the circuit that every tenant on the setup key shares. The stored provider error now names the status for these failures too, so their error fingerprint changes once. A cancellation that a tool or a Tracon ring raised no longer counts against the provider’s circuit; the provider client’s own timeout still does.

  • A queued agent run, or the agent run of an inbound trigger, no longer stays Queued forever when the queue refuses its job. The placeholder row was written first and never closed; it is now closed as Failed, as the queued workflow run already did, and the request still fails.

  • A batch item or an eval case whose run stops at a tool approval now fails, and its run is closed as Failed. Both run without a session, so the approval could never be answered: the run stayed AwaitingApproval with no inbox entry, and the batch reported the item Completed. The queued agent run already had this rule.

  • A document whose text the content guard masks stays a document in the run record. The masked message was rebuilt as plain text, so RunStarted.Text held the document’s text and no DocumentAttached event was written. The record now takes the name and the text as they read after masking, or the name [masked document] when the masked text no longer reads as one document. The original name and summary are not recorded.

  • The model calls of an evaluator added with AddEvaluatorJudge are now recorded. Each judgment writes one judge:{name} run of kind Eval with the calls’ usage and cost, and the cost reaches the judge cost metric, as the built-in model judge already did. Before, nothing wrote this spend down. A failing evaluator closes its run as Failed and still throws.

  • The in-memory job store now changes a job item only while it is pending, as the SQL stores do. It overwrote a finished item, so a late report from a worker whose lease had expired rewrote the result the job already gave.

  • Scheduling:Lanes, Scheduling:MaxConcurrentJobsPerLane, Scheduling:LaneByHandlerKey and Scheduling:HttpSchedulableHandlerKeys are now read from configuration. The four keys were documented and bound nowhere, so a lane budget in appsettings.json was ignored and the worker leased every lane. A list that is present replaces the default. A per-lane budget that is not a number now fails at startup with the lane named, instead of being skipped.

  • A webhook delivery now honors Egress:AllowPrivateNetworkTargets. The subscription was saved under that setting, but the delivery job checked the target against the default egress options and dropped every delivery to a private address; only Webhooks:AllowPrivateNetworkTargets worked.

  • A queued run no longer fails, or runs a second time, when its session cannot be saved once after the model has answered. The handlers that end a queued turn (a queued run, the continuation of an approved tool call, the continuation of an interrupted run) saved the session inside their own failure path: one store error failed the job while its run said Completed, or released the job and called the model again. The save is now tried again with a growing pause while the worker holds the lease. If it still fails, the job fails with tracon.job.session-save-failed, whatever its attempt limit, and the model is not called again. A run that asked for approval closes Failed with the same code, because its approval cannot be answered without the session.

  • A run that resumes after an approval decision and asks for another approval now puts that request in the approval mailbox. Only the queued-run handler wrote pending approvals, so the resumed run closed AwaitingApproval, GET /api/approvals/pending answered an empty list, and the chain stopped. A run that continues an interrupted run had the same gap. Both now save the session first, list the approval second and announce it with approval.pending, as a queued run does.

  • The sub-agents and tools of a queued run now see the run’s labels, and those of a resumed or continued run see its user and labels. The job payload now carries labels next to userId, and a resumed or continued run runs under the user and labels of the run it continues, not the approver’s. A workflow or agent batch that runs from the queue now carries the user and labels of whoever queued it: IJobDispatcher captures the attribution IRunAttributionContext resolves at the time of the call, and an inbound trigger keeps the accept request’s (an agent run it starts recorded none either). A schedule’s payload can name one in an envelope, {"items": [...], "userId": "...", "labels": {...}}; an envelope that breaks a label limit is refused with 400. A context you register yourself sees the replayed attribution only if it reports AmbientRunAttributionScope outside a request; the reference sample’s demo context now does.

  • An evaluation run whose cases have all run is no longer marked Failed when the write that closes it fails. The handler now tries the write again for a third of the job lease; if the store still refuses it, the next attempt runs no case again and only repeats the write.

  • A webhook delivery the receiver accepted is no longer sent again because Tracon could not record the result. The result writes are now tried again for a third of the job lease, a write that still fails is logged with a reference, and a retry that finds the delivery already settled sends nothing. Delivery stays at-least-once: deduplicate on the X-Tracon-Delivery header.

  • An eval case’s context now reaches the agent, through the document channel ahead of the query, as a run with documents sends it. A case ran as its query alone and the context went to the evaluator only. The evaluator still receives it, and the case run now records the parameter values it was bound with.

  • Promoting a run to an eval case now copies the run’s recorded input: the whole query, the documents it carried (into context, one block per document) and the parameter values it was bound with (into parameters). It read only the RunStarted event, which is cut at MaxPayloadLength (8 KB by default), and dropped the documents and the values. Without a recorded input the query still comes from that event and keeps its …[truncated] marker; a run that carried documents with no recorded input, an attachment, or a message besides its documents and its request is now refused with 422. Requirement: an IEvalStore stores the new EvalCaseDraft.Parameters in AddCaseAsync.

  • On a SQL provider, a replay, a continuation or an approval resume of a run that carried documents now records them as documents. The stored document marker came back as JSON values the recorder did not recognize. The console’s case editor also keeps the line breaks of a case’s context.

  • A fallback on the binding of the compaction summarizer or of an aiJudge loop judge no longer rewrites the run. runs.model_id named the judge’s fallback model, the agent’s own tokens were priced at that model, and the run got a ModelFallbackUsed event its own model never caused. The run now keeps the agent’s model and gets no event, the side call’s tokens are priced at the fallback model that answered, and the switch is logged.

  • POST /api/workflows/runs/{runId}/respond now refuses an answer that does not fit the pending request’s port with 400 before the event stream opens, as its contract said, and opens no run. The answer was translated only inside the resumed run, after the 200, and the new run ended Failed. A request recorded by 1.0.0-preview.3 or earlier does not carry its response type’s assembly; for it a data value that does not read into the type is still reported on the stream.

  • Saving a Sequential node list now checks that each node takes what the node before it passes on, and that the first node takes the run’s List<ChatMessage>. Microsoft Agent Framework drops a message no handler takes without an error, so such a chain was saved with 200 and every run ended Completed with no output. The save answers 400 naming both nodes and both types, and a stored chain that does not fit now fails its run before the first node. The site, the Tracon.Workflows README and the AddWorkflowFunction examples showed a string function after an agent; they now take List<ChatMessage>.

  • Automatic run continuation no longer treats a workflow run or a sub-run as an agent turn. An orphaned workflow root was queued as an agent continuation under the workflow’s name, and a workflow step or a callable agent’s run was continued on its own, repeating its tools in its root’s session. Such a run now stays Failed with a RunContinuationBlocked event; for a workflow run the event names POST /api/workflows/runs/{runId}/resume. A continuation queued before the fix is refused the same way. The refusal event is now written for a run of any tenant, and MaxAttempts above 1 counts every earlier continuation of another tenant’s chain.

  • A retention policy with archive: true no longer deletes rows it never archived. Each batch was read in time order for the IArchiveSink and then deleted as a separate batch in no order; with more matching rows than one batch, rows that never reached the sink were deleted (measured on PostgreSQL, SQL Server and SQLite). A run now deletes exactly the rows the sink received, by key, after the sink returns. A sink can receive a row more than once.

  • Documentation now matches the code: a failed workflow node’s message rule and the causes of a Canceled workflow run, the request options a fallback link inherits (temperature, TopP, MaxOutputTokens, ResponseFormat and tools carry over; only provider settings do not), the five gates and the waiting quota of a tool call, the console editor keeping a harness loop, and how a job handler learns about cancellation (JobContext.IsCancelledAsync). The XML of JobHandlerKeys.Workflow, TraconOptions.SkillRoots, MaxOutputBytes (per stream), ToolCallUsage, ModelFallback and ModelBinding was corrected. A resume from a checkpoint whose graph changed now says the graph’s structure differs instead of naming one cause.

  • Every documented webhook event is now published. workflow.request.pending, job.completed, job.failed and eval.completed were defined, accepted on subscribe and listed on the site, but no code published them, and a workflow run produced no run.* event at all (its agent steps are child runs). A workflow root now publishes run.completed/run.failed with the run tree’s tokens and cost, and one workflow.request.pending per request it waits on (ids only, no prompt). A job publishes job.completed or job.failed when it reaches that status (a cancelled job and a webhook delivery job publish nothing). An evaluation run publishes eval.completed when it completes, is cancelled, or fails on its last attempt. The console’s subscription form now also offers run.score.low.

  • Retention and data subject erasure now delete an attachment’s bytes in a registered IAttachmentStorage. Retention’s attachments target never read external_uri, so the bytes stayed in object storage for ever; it now deletes the bytes before the row, and keeps the row (and tries again on the next run) when the byte delete fails. Erasure deletes the bytes after the row deletes and before the audit write and the commit, and rolls back when a byte delete fails. IAttachmentStore.DeleteAsync and DeleteBySessionAsync also delete the bytes first: a storage failure after the row was gone lost the location for good. Requirement: an IAttachmentStorage.DeleteAsync must succeed for a location that no longer exists.

  • Retention no longer treats a workflow’s attachments as orphaned while a run of that session waits for input, is still running, or finished after the cutoff. A workflow session has no session row, so an image or a speech clip an agent step produced was deleted seven days after it was made, even while the run waited in AwaitingInput. Ownership also requires the same tenant.

  • The continuation that a tool-approval decision queues now runs on the agent version of the run that asked for approval, and counts in the same experiment arm. It ran the current version, so a version saved while the approval waited changed the instructions halfway through the turn. Code agents still run their current definition. An automatic continuation after a crash now keeps the interrupted run’s experiment arm, not only its version.

  • A queued run (Prefer: respond-async) now uses the request’s culture and takes part in a running experiment. The worker resolved the agent with no culture and the current version, so a queued run always got the default instructions, ignored the experiment and counted in no arm. The arm is now assigned when the request is accepted, with the same key as a synchronous run (sessionId, else the run id), and the worker runs that arm’s version. The worker honors an arm in the job payload only when it matches a started experiment of the job’s tenant and agent with that exact version; otherwise the job fails and no model call is made.

  • A store error after a job’s handler has finished no longer counts as a handler failure. The worker wrote the outcome inside the handler’s own try, so one failed database call either failed a job whose work was done — a queued agent run (one attempt by default) became Failed while its run was Completed — or, with attempts left, ran the handler a second time. The worker now retries the write with a growing pause while it can still prove its lease, and stops a third of the lease before that lease could run out; a write that keeps failing is logged as an error and the job takes the lease-expiry path. A store error while releasing a failed attempt, or while failing its last one, no longer leaves the job Running until its lease expires. In a batch, a store error while recording a finished item no longer records that item as Failed.

  • A workflow run now carries its attribution the way an agent run does. The root built its run scope before it read IRunAttributionContext, so every node and tool of a workflow read TraconRunContext.Current.UserId and Labels as null while the run row carried them, and its quota record named no run and no user, so a quota.threshold webhook for a workflow carried runId and userId as null.

  • A harness loop with an aiJudge criterion now records the judge’s tokens in the run’s usage, cost and quota consumption. The judge calls its own model on every iteration it reaches, and the framework drops that response: the tree budget counted the judge, but the run record, the run’s cost and the tenant’s quota did not.

  • The tokens of the context compaction summarizer are now priced at the summarizer’s own model. They were priced at the agent’s model, while the tree budget priced them at the summarizer’s.

  • The tree budget now charges a streamed model call with the sum of its usage reports. It kept only the last report, while the run record sums them, so a call that reports usage in more than one part was undercounted against MaxTotalTokens and MaxTotalCost.

  • A consumer’s own speech provider now works with UseVoice() as documented. With ISpeechSynthesizer and ISpeechTranscriber registered before UseVoice() and no voice key, the first read of the voice settings threw: POST /api/voice/speak called the provider, saved the attachment, then answered 500 and left the attachment behind. Provider, ApiKey and Endpoint are now validated only while the built-in ElevenLabs client serves at least one of the two contracts; a registration added after UseVoice() fails at startup with a message that says to register first. Three behaviors now follow the bound implementation instead of the ElevenLabs settings: prices are looked up under its ProviderName (Tracon:Pricing:Voice:{ProviderName}:{model}, and only the built-in client falls back to its default model), the speak tool applies ISpeechSynthesizer.MaxCharactersPerRequest as the HTTP endpoint already did, and GET /api/voice/health uses the synthesizer’s own IVoiceHealthCheck or a voice-list call that reports its ProviderName.

  • Speech audio saved by POST /api/voice/speak is now named after its detected type (.ogg, .wav, .mp3) instead of always .mp3, and audio a live conversation saves with PersistAudio is no longer named .bin. Speech, conversation and image attachments now share one naming rule, and their timestamps carry ticks.

  • The console can now answer a workflow request that it did not start. The run page sent the operator to “the workflow screen”, but that screen showed a request card only for a run it had started itself, so a run started over HTTP, by a job or in another tab could not be answered from the console at all. The workflow screen now opens on the run named by ?runId=. Both links on a run page carry it, and the screen writes its own run there, so a reload or a copied link keeps the run. A runId of another workflow, or of a run that is not waiting, shows a note instead of a card. The run page now also says that answering an already answered run opens a separate branch.

  • A workflow node retry, the model fallback chain and an online evaluation judge no longer repeat a permanent client error. RunErrorClass.ProviderError covers every provider HTTP error, 4xx included, and the node retry read only that class: a node that got a 401 from EnsureSuccessStatusCode(), or a model call that got a 404 (model_not_found), ran WorkflowNodeRetryPolicy.MaxAttempts times, and a retried 409 on a non-idempotent call could repeat its side effect. The fallback chain moved to the next provider on any 4xx other than 401/403 that an SDK exception type carried, so the next provider got the same bad request, and an online evaluation job retried a judge whose model answered 401 or 404. A failure that carries a permanent HTTP status (every 4xx except 408 and 429, read from HttpRequestException.StatusCode or from HTTP nnn text in the exception chain) is now thrown on the first attempt, never moves to the next link, and fails the judge’s item without a retry. Behavior change: a registered IRunErrorClassifier can no longer make such a failure retry a workflow node; an IProviderRetryClassifier that returns Retry still moves to the next link. runs.error_class values are unchanged.

  • A harness agent on the OpenAI Chat Completions provider (openai, azure-openai and a compatible registration) no longer fails with 400. The harness sent the provider’s hosted web search on every request (unknown_parameter: web_search_options for a model that does not search; it is now off by default, see Changed), and a streamed run lost the conversation after its first tool call: the next request carried only the system message and the tool result (messages.[1].role). The harness’s history layer wrote a marker onto each streamed update, and the tool loop under it, holding the same object, took the conversation for one the service stores. Each update now reaches the harness as a copy.

  • HarnessSettings.MaximumIterationsPerRequest now bounds the tool-call loop. It reached the harness’s own loop, which never sees a tool call: the loop that runs the tools kept its default of 40, so a limit of 2 still made 7 model calls. The limit now reaches that loop and the loop of every fallback model, and at the limit the last call offers the model no function tools. A value below 1 is refused when the agent is compiled.

  • A fallback model runs the agent’s tools with the agent’s ModelBinding.AllowConcurrentToolCalls. The fallback link built its client from the provider and model alone, so concurrent tool calls turned off when the chain moved to a fallback.

  • A workflow run whose node works quietly for a while is no longer cancelled by its own silence. The workflow stream wrote nothing between events, the console stops reading a stream after 30 seconds of silence, and a closed connection cancels the run - so any node that took longer ended the run Canceled. Every streaming endpoint (agent and workflow runs, resumes and answers, and the OpenAI-compatible endpoints) now writes an SSE comment whenever it has written nothing for TraconEndpointOptions.StreamKeepAliveInterval (15 seconds by default; see Added). A workflow run that waits for a MaxConcurrentRuns slot now has its run record from the start: a run whose RunTimeout passed while it waited closed the stream with no closing frame and left no record behind; it now ends Canceled with the timeout error like any other timed-out run. Any other error on the workflow stream now ends it with an error frame instead of a dropped connection.

  • The job queue now keeps its lease, attempt and cancel promises. A failed lease renewal stopped renewal for the rest of the job, so the lease ran out under a working handler and a second worker ran the job alongside it; renewal now retries on the next tick and runs at a third of the lease instead of half. A job whose worker died was leased again without checking its attempt limit, so a queued agent run (one attempt by default) ran twice; a job past its limit now fails without running, with the error code tracon.job.attempts-exhausted. A cancelled job whose handler then threw went back to Pending and ran again, or became Failed; a job that reached a final status no longer changes, and a job cancelled between its lease and its start does not start.

  • A workflow step whose agent cannot answer now fails the workflow. A step that ran past AgentGraph:ChildDeadline, was refused by the tree budget (MaxTotalRuns, tokens, cost or time), asked for a tool approval, or whose provider never answered returned the refusal sentence as its answer: the next node received that sentence as its input and the run ended Completed. The run now ends Failed: a wait limit with the new TraconChildRunTimeoutException (class Timeout), the budget with TraconRunBudgetExceededException (class QuotaExceeded), a provider fault with the provider’s own error. An agent called as a tool by another agent still gets the refusal as text, so its model can react.

  • A pending human request of a workflow run can no longer become impossible to answer. A long prompt - about 1,400 non-ASCII characters is enough - was cut at the event payload limit, the request disappeared from the pending list and respond refused it; the request is now listed with its prompt cut and marked, and it can be answered. Retention deleted the request event and the checkpoints of a run waiting for an answer; it now keeps them for as long as the run waits. With KeepCheckpointsAfterCompletion = false, an answer - even one the port rejected - deleted the checkpoints the waiting run needs; the cleanup now leaves a session’s checkpoints in place while another run of the session waits or is running.

  • A harness loop run now records the tokens and cost of every iteration. A non-streaming loop returned and recorded only its last iteration’s usage, and a streaming run of any agent recorded only its last model call - a loop, or a run with a tool round, undercounted its cost and the tenant’s quota. The usage of a streamed run is now the sum of its model calls.

  • Structured-response validation now checks the agent’s answer, not everything the response holds. With a loop, or with text the model wrote before a tool call, the validator received the joined text and rejected a valid final JSON as “not valid JSON”. It now validates the last assistant message that carries text.

  • A parameterized agent no longer runs with its placeholders unfilled. Only POST /api/agents/{name}/run and evaluations bound parameter values; a queued run, a batch, a replay, the OpenAI-compatible endpoints, MCP, A2A, a workflow step, an agent called as a tool and voice sent the model the literal {{name}}, without even the defaults. Every path now binds the defaults. An agent with a required parameter that has no default is refused where the request enters - 400 with missingParameters for a queued run, 400 on the OpenAI-compatible endpoints, an error for an MCP or A2A call, a failed job for a batch - and fails before any provider call on a path with no request to refuse. A replay and an automatic continuation are refused for any agent that declares parameters: the recorded input has no parameter values, and the defaults would change the instructions. An evaluation of a parameterized definition registered in code now binds each case’s values. A parameterized definition registered in code (AddAgent(AgentDefinition)) is now checked and bound on POST /api/agents/{name}/run; its parameters were refused there as unknown.

  • A run pinned to an earlier definition version (an evaluation or an experiment) is now validated, priced and recorded as that version. The catalog compiled the pinned version but described it with the current one, so its response was validated against the current response format, and the run recorded the current model, cost and - for an evaluation case - version. An evaluation run now records the pinned version’s model.

  • GET /api/evals/runs/{id} no longer fails with 500 on the in-memory store when a case’s run threw. A free-form JSON field left unset (Scores, Checks, a job’s Payload) held no JSON at all and could not be written; it is now the empty array, as when it is set to nothing.

  • A dependency that times out on its own no longer counts as the caller cancelling. An HTTP-backed store, sink, catalog, cache or identity seam reports HttpClient.Timeout as TaskCanceledException; 58 catch sites let that exception through as if the request had been cancelled, which broke each site’s own promise. Among the effects: the MCP and A2A startup check stopped the host when the agent catalog timed out, a run failed because a run store, event sink, response cache or attribution lookup timed out, an approved turn never ran because its “don’t ask again” rule could not be written, a trigger delivery whose queueing timed out could never be retried (the retry was refused as a replay), queued runs and evaluation runs stayed Queued/Running forever, and image generation and replay answered 500 instead of their documented 502. Every site now tells the two apart by the operation’s own token.

  • An IRunEventSink now receives a run’s events in sequence order, as its contract states. A run writes from several places at once (concurrent tool calls, a child run, a workflow’s steps), and an event numbered later could reach the store and the sinks before an earlier one. Numbering, the store write and the sink dispatch of one event are now a single step. A sink must not append to the run it is handling: such an append throws InvalidOperationException and the sink is disabled for the run, instead of waiting for itself. The in-memory run store also returns events in sequence order.

  • Editing a workflow that uses function nodes in the console no longer erases them. The editor opened such a workflow with an empty participant list, and saving it after adding an agent replaced the whole chain with that agent. The editor now lists the chain read-only, keeps the kind Sequential, and sends the chain back unchanged.

  • Editing a quota in the console keeps a disabled rule disabled. The form sent enabled: true on every save. The agent and the period of a rule are now locked while you edit it: they identify the rule, and changing them wrote a second rule.

  • Editing a draft experiment no longer removes its canary policy when the in-memory store is in use. The SQL stores already kept it.

  • Saving a webhook subscription through PUT /api/webhooks/{name} now resets its consecutive failure count with the SQL stores too, as the in-memory store already did. Before, a subscription that was disabled for failing and then enabled again went back to disabled on its next single failure.

  • A live voice session, a voice conversation and an agent batch job now pick up an MCP server change on their next delegation, turn or item. Each resolved its agent once and ran with it until it ended, so after a server was edited or deleted mid-session every later call went to the closed connection (the model heard “Error: Function failed.”), and a server switched back to requiring approval ran its tools without asking. They now resolve the agent again each time; nothing is recompiled while nothing changed. A batch item whose agent was deleted meanwhile fails on its own.

  • A cached agent now sees an MCP tool’s description and input schema as the server last listed them. The compiled agent kept the tool definition of the moment it was compiled, so after a server changed a tool on the same connection, the model kept getting the old description and schema until the agent’s definition changed or the process restarted.

  • One failed tools/list call no longer takes an MCP server’s tools away. A server whose listing failed once lost all its tools at that refresh, and every agent that used one was refused as “not registered” until the next good refresh (five minutes by default). The last good list is now kept for Tracon:Mcp:StaleToolRetention (default 15 minutes) after it was read, while the server stays registered with the same definition. TimeSpan.Zero keeps the old behavior.

  • A timeout set beyond what .NET timers accept now fails at startup with the option’s name. CancelAfter refuses more than about 49.7 days, and the validators refused only zero and negative values, so such a value threw where the timeout was armed: on every run of a workflow (Workflows:RunTimeout), every webhook delivery (Webhooks:Timeout), every live voice delegation (Voice:Live:DelegationTimeout), every MCP connect (Mcp:ConnectionTimeout, which aborted discovery for every tenant), at shutdown (Drain:Timeout), and after a skill script had started, which left the script running (Skills:Scripts:Timeout). The same check now covers Tools:DefaultTimeout, Tools:ApprovalPresentationTimeout, Validation:McpTimeout, AgentGraph:ChildDeadline and WaitTimeout (and an agent definition’s subAgents values, at compile time), Images:Timeout, Voice:Conversation:IdleTimeout and MaxConnectionDuration, a tool’s own timeout, and RunEventPollInterval. A provider or voice Timeout, which becomes an HTTP request timeout, must be at most about 24.8 days (int.MaxValue milliseconds); TraconClientOptions.Timeout likewise. A WorkflowNodeRetryPolicy with a negative delay or multiplier is refused when the function is registered, and its growing delay is capped. tracon eval refuses a --timeout or --poll-interval it cannot wait for. Values below one millisecond are refused too.

  • On SQL Server, two requests that save the same new record at the same time no longer fail with a unique-key violation. Twenty stores wrote with an update-then-insert pair outside a transaction, so both requests could take the insert branch: among them sessions, runs, quotas, script grants, approval rules, MCP servers, tenants and the singleton lease. Each pair now runs inside a transaction, which costs two round trips on SQL Server and nothing on PostgreSQL or SQLite. A script grant, quota or approval rule saved with an empty string in a nullable key is no longer matched to the record saved with null there; the HTTP surfaces already store an empty value as null.

  • A provider setting value of the wrong JSON type (for example text where anthropic.thinking.budgetTokens expects an integer) is now reported with the key it belongs to. Through the model provider registry the message was replaced with “The model provider request failed.”, so POST /api/agents/validate and a compilation error did not name the key.

  • A run that failed because the provider filtered its response (ContentFiltered) now records the tokens the call spent in its usage, cost and quota entry. The run recorder writes no usage for a run that ends in an exception, so a filtered run said it cost nothing although its input was billed.

  • Function nodes and agent steps for a workflow graph built in code. GetWorkflowFunction(name[, configure]) binds a function registered with AddWorkflowFunction and GetWorkflowAgentStep(workflow, agent[, configure]) binds a catalog agent as a plain step, so a hand-built graph with a way back and a human gate gets what a stored chain’s steps have: the time limit, the retry policy, the budget gate, a cost line that names the node, the strict read of the settings (WorkflowFunctionStepOptions.Settings, or typed with SetSettings) and an agent step’s own timeout and version pin (WorkflowAgentStepOptions). Until now the wrapper that carries them was reachable only from a stored chain’s compiler, and the budget gate could not be built in consumer code. An agent bound with GetWorkflowAgent still cannot be the target of a back edge (it runs only after a turn token), so the agent step is the way to put an agent where a message returns. An unknown function or settings that do not fit end the run Failed before its first node, naming the workflow and the node; a version pin that cannot hold fails the step by name before its model call.

  • WorkflowRegistrationOptions.RunTimeout and WorkflowRegistrationOptions.Parameters give a workflow defined in code the run time limit and the parameter schema a stored definition has. The run timeout shortens Tracon:Workflows:RunTimeout or lengthens it up to MaxRunTimeout, and a value above the ceiling stops the host at startup, naming the workflow. With a schema, GET /api/workflows lists it, the run endpoint answers 400 early for an unknown or missing value, a queued run carries the values, and a run that is answered or resumed uses the recorded ones. Without one, every value is still refused as unknown.

  • WorkflowFunctionOptions.MaxConcurrency limits how many calls of a function run at the same time in the process, across workflows and tenants, in a stored chain and in a graph built in code. A call that finds every slot taken waits; the wait ends when its run is cancelled or reaches its deadline and is not part of the node’s Timeout; each attempt of a retried node takes a slot of its own; the budget gate is read after the slot is taken. The limit is per process, so replicas multiply it.

  • A workflow run that the request expiry service fails now publishes run.failed, with the payload any failed workflow run carries. Until now such a run was closed in the store and the webhooks heard nothing, so a pipeline driven by webhooks waited for a run that had ended. A run closed Completed (every request answered) publishes nothing: its answer’s own run published run.completed.

  • A list of the human requests a tenant’s workflow runs wait on, and a time limit for them. GET /api/workflows/requests (state is pending, answered or all; workflowName, skip, take) lists one item per request, oldest first, across every workflow; it needs the Reader role and read access to runs, and a registered IRunAuthorizationHandler that refuses reading runs rejects it with 403 instead of filtering it. WorkflowPendingRequest gains workflowName, sessionId, expiresAt, answered and lastResponseRunId, on this list and on GET /api/workflows/runs/{runId}/requests; IWorkflowRunner gains ListRequestsAsync, with a default that lists nothing. The same request id in the same session is one request: an answered run stays AwaitingInput and its continuation publishes the open requests again, so the item names the newest run that waits on it. The list is built from a new workflow_requests table (one migration for each SQL provider) that the engine writes when a run starts waiting and the respond endpoint marks answered; it holds no copy of the prompt, which is read from the run’s event. A run that started waiting before the migration has no row and is not in the list. The console lists the same requests in a Workflow requests panel on the Approvals screen.

  • A workflow can give its human requests a time limit. WorkflowDefinition.RequestTimeout (requestTimeout on PUT /api/workflows/{name}, the Answer time limit field in the console’s editor, WorkflowRegistrationOptions.RequestTimeout for a workflow defined in code) is from one minute to 365 days and counts from the moment the run published the request. A background scan (Tracon:Workflows:RequestExpirationScanInterval, one minute, and MaxRequestExpirationsPerScan, 100; one instance of a cluster scans) closes the run when its last request passes: Failed with the error type WorkflowRequestExpired (error class Timeout) when something went unanswered, Completed without an error when every request had been answered. An answer to a closed run is refused with 400, as for any run that is not AwaitingInput. The run keeps its usage, cost and event count. A run that waits keeps the limit of the version it started with. This is what lets retention release what a waiting run keeps: its request event, checkpoints and run input row were never deleted while it waited, and an answered run waits too. The new workflow_requests retention target (Tracon:Retention:WorkflowRequests, 30 days after the request expired) deletes the closed rows of the list. Behavior change: requests joins functions as a workflow name that cannot be saved, because GET /api/workflows/requests matches before GET /api/workflows/{name}.

  • Tracon.Testing.Contracts.Xunit: WorkflowRequestStoreContract for IWorkflowRequestStore, the seam behind the list; a custom store passes it the way it passes the other store contracts. The request store also lists the runs a stopped process left waiting (IWorkflowRequestStore.ListStrandedAsync): the scan closes a request before its run, and the instance that takes the scan lease next closes the runs whose requests were closed and which still wait. WorkflowDefinitionStoreContract gains The_request_timeout_survives_the_round_trip; a custom IWorkflowDefinitionStore that keeps its own copy of a definition must carry RequestTimeout.

  • One answer per workflow request. WorkflowResponseMode (Branch, the default, and SingleAnswer) is set on a stored definition (WorkflowDefinition.ResponseMode, responseMode on PUT /api/workflows/{name}) or on a workflow defined in code with the new AddWorkflow(name, factory, Action<WorkflowRegistrationOptions>) overload, and is reported on WorkflowDescriptor. In SingleAnswer, two identical answers to one request — a double click, a retried call — start one run: the second follows it (the same SSE frames read from the stored run, or the same 202 with Prefer: respond-async), and a different answer gets 409, which names neither the run nor the other answer. An answer whose run failed or was cancelled before the workflow received it can be given again and starts a new run; a queued answer whose job is still waiting or running is followed instead, because the job’s recovery owns that work. Two answers that arrive together may both open a run before one of them claims the request: the other closes its own run as Canceled with the error type request_already_answered and no events (no webhook, metric or event sink fires for it), and its audit entry names that run. That run is still a run: it is listed by GET /api/runs and counted in RunStatistics.CanceledRuns, so it is part of the ErrorRate denominator. A waiting run is answered under the mode of the definition version it recorded. Branch keeps opening a new run for every answer. A queued SingleAnswer answer whose job was never written (the process stopped after the answer was recorded and before the job was queued, or the queue refused the job and its Queued run could not be closed) is finished by a repeat of the same answer: the repeat writes the job and follows the run, and two repeats at the same time write it once. Known limit: when a process stops in the middle of a streamed SingleAnswer run, the run stays Running and a repeat of the answer follows it forever. Only run reconciliation (Tracon:RunReconciliation:Enabled, off by default) closes it: reconciliation closes Running runs whose heartbeat stopped and reads no Queued run. Turn reconciliation on where such a request must stay answerable after a crash.

  • GET /api/workflows/runs/{runId}/responses lists the answers given to a run’s requests: who answered, when, the run each answer started and its state. The records live in the new IWorkflowResponseStore (in memory by default; the new workflow_responses table on PostgreSQL, SQL Server and SQLite). A record is removed only with its source run, by a data subject erasure; no retention target deletes runs, so the table grows with the run history. A store implementation is checked with the new WorkflowResponseStoreContract in Tracon.Testing.Contracts.Xunit.

  • RunEventType.WorkflowResponseApplied (workflow.response-applied on GET /api/runs/{runId}/events): the run an answer starts names the run it answered and the request id right after it starts, even with RecordToolPayloads off. The console’s run timeline links it back to the source run.

  • The console shows which pending requests already have an answer, links the run that answer started, says when a one-answer workflow refused a different answer, and has a “One answer per request” switch in the workflow editor and the version comparison.

  • The live stream of an agent run (POST /api/agents/{name}/run without Idempotency-Key) now says when the answer was cut off. A plain-text answer the model stopped at its output token limit or at its context window completes, and its tracon.output.truncated event (RunEventCustomTypes.OutputTruncated) was only on GET /api/runs/{id}/events; a client that read the live stream took the partial answer for a complete one. The stream now sends that event as a custom frame right before done, always, with the same data /events serves. Each recorded event is one frame: a harness loop whose iterations were cut sends more than one, and a tool turn cut off in the middle of the run sends none. With the quota notice on, both notices arrive in record order. A client that reads frames by name and ignores unknown customType values needs no change. No event, and so no frame, is written while run recording is off.

  • TraconFinishReasons.ContextWindowExceeded: the finish reason a model provider reports when the conversation and the answer filled the model’s context window. Tracon treats it as a cut-off answer and names the window.

  • RunErrorClass.TenantProviderRefused (17): the tenant’s provider rules refused the call before any provider was called.

  • TraconTenantProviderRefusedException: the one exception every path throws when the tenant’s provider rules refuse a call (its egress policy, a credential binding that cannot be resolved, a provider that cannot take the tenant’s own key). Catch it to tell the tenant’s rule from a provider failure.

  • CurrencyCount on RunTreeCost, RunNonTokenCost, RunStatistics, TimeSeriesPoint and ExperimentVariantResult, and JudgeCostCurrencyCount on OnlineEvaluationSummary: how many currencies the runs behind a money total are priced in. Above one, the total carries no amount (see Fixed).

  • Skill scripts can run outside the Tracon host. Register an ISkillScriptExecutor (new in Tracon.Abstractions, with SkillScriptExecutionRequest, SkillScriptOrigin and SkillScriptExecutionResult, which moved there from an internal Core type without its Duration) to run an approved script in a container, a micro-VM or a remote runner. The gates, the content pin, the script.run audit entry, the concurrency quota, a wait limit of Timeout plus five seconds and the output cap stay with Tracon; the executor decides only where the script runs. UseSkillScripts registers the built-in executor, a local process as before, as a default that a host’s own registration replaces in either order; RequireCustomBinding<ISkillScriptExecutor>() stops the host while the built-in one resolves. Tracon.Testing.Contracts.Xunit adds SkillScriptExecutorContract and ContractCoverage.ScriptContracts. See the new guide Write your own script executor.

  • Multi-speaker dialogue synthesis. ISpeechDialogueSynthesizer voices an ordered list of turns, each with its own voice, in one request (SpeechDialogueRequest, SpeechDialogueTurn) and returns one audio result with each turn’s time range (SpeechDialogueAudio, SpeechDialogueSegment) and the provider’s limits (SpeechDialogueCapabilities). Code takes it from the new ISpeechProviderResolver.GetDialogueSynthesizerAsync(name), which reads the capability from the registered synthesizer and applies the tenant’s egress policy and own-key rules; nothing registers the capability in the container. The built-in ElevenLabs client implements it with text-to-dialogue and its timestamped variant, refuses a request over 10 voices or VoiceOptions.MaxDialogueCharactersPerRequest (new, default 2,000) before the call, and uses VoiceOptions.DialogueModelId (new) or the provider’s default model, never SynthesisModelId.

  • Provider request settings for speech. SpeechRequest.ProviderSettings and SpeechDialogueRequest.ProviderSettings carry keys under the provider’s prefix; the built-in client reads elevenlabs.stability, similarityBoost, style, useSpeakerBoost, speed, seed, previousText, nextText and previousRequestIds (ElevenLabsProviderNames) and refuses an unknown key, a wrong type, a value out of range, and on a dialogue a key the dialogue service does not take. ModelProviderSettings.Validate gains an overload that takes the settings dictionary, for a speech provider of your own.

  • SpeechAudio.ProviderRequestId carries the provider’s id for a generation (the ElevenLabs request-id header), to pass in elevenlabs.previousRequestIds for the next part of a long script.

  • A workflow function node can be bounded by a timeout. AddWorkflowFunction<TInput, TOutput> gains an overload that takes Action<WorkflowFunctionOptions>, and WorkflowFunctionOptions.Timeout sets the registration’s timeout; a node’s own WorkflowNodeReference.Timeout (timeout in PUT /api/workflows/{name}) wins over it. When it passes, the handler’s token is cancelled and the run ends Failed with the new TraconWorkflowFunctionTimeoutException (error type workflow_function_timeout, class Timeout). The run stops waiting even for a handler that ignores its token; its late end is logged and its result is discarded. With a retry policy the timeout applies to each attempt. GET /api/workflows/functions shows the registered timeout (WorkflowFunctionResponse.Timeout).

  • An agent node of a Sequential node list can carry its own timeout, which replaces Tracon:AgentGraph:ChildDeadline for that step; the hard cutoff stays 30 seconds later. Past it the run ends Failed with child_run_timeout, and the message names the node.

  • WorkflowDefinition.RunTimeout (runTimeout in PUT /api/workflows/{name}): a definition’s own run timeout. It may shorten Tracon:Workflows:RunTimeout, and may be longer only up to the new TraconWorkflowOptions.MaxRunTimeout (Tracon:Workflows:MaxRunTimeout, unset by default); a longer one is refused with 400 on save, and a stored one above a ceiling lowered later is cut to it with a warning.

  • A job’s time limit: JobRequest.MaxDuration and JobRecord.MaxDuration, and TraconSchedulingOptions.MaxDurationByHandlerKey (Tracon:Scheduling:MaxDurationByHandlerKey) for every job of a handler key; the job’s own value wins. Past it the worker cancels the handler’s token and the attempt fails with JobErrorCodes.MaxDurationExceeded (tracon.job.max-duration-exceeded); the job is leased again with the full limit while attempts are left, and a handler that returns normally after the limit completes the job. The built-in workflow handlers end the work instead: the run closes Canceled with the error type job_max_duration_exceeded and is never continued, a queued run’s job fails at once, and a batch fails the item. New column jobs.max_duration_ms (migrations PostgreSQL 0057, SQL Server 0045, SQLite 0044). JobStoreContract adds A_jobs_own_time_limit_survives_the_round_trip_and_the_lease and A_job_without_a_time_limit_reads_back_none; WorkflowDefinitionStoreContract adds Node_timeouts_and_the_run_timeout_survive_the_round_trip and Saving_again_without_timeouts_clears_them. A third-party store must keep both fields.

  • The console’s workflow editor has a timeout field on each step of a chain with a function step, and a run timeout field.

  • A workflow run, resume or answer can be queued: POST /api/workflows/{name}/run, POST /api/workflows/runs/{runId}/resume and POST /api/workflows/runs/{runId}/respond accept Prefer: respond-async and answer 202 Accepted with the run id (AcceptedRunResponse, now in the OpenAPI document and both generated clients). The caller is checked before the 202 as on the streamed call, the run’s row is Queued until a worker starts it, and POST /api/runs/{id}/cancel cancels it from the queue. A queued run no longer depends on the connection, so a client that stops reading does not cancel it. The job runs under the new built-in key JobHandlerKeys.WorkflowRun (tracon.workflow-run), which no schedule can use.

  • Idempotency-Key on the three workflow run endpoints, accepted only together with Prefer: respond-async; a key on a streamed call is refused with 400.

  • TraconWorkflowOptions.ResumeInterruptedRuns (off by default) and MaxResumeAttempts (default 1): a queued workflow run or a tracon.workflow batch item interrupted by a crash, a deploy or a graceful stop continues from the latest checkpoint in its chain when its job is leased again, as a new run whose ContinuedFromRunId names the interrupted one. The step that was running repeats. Turning it on without EnableCheckpointing fails at startup. A queued run’s job gets 2 + MaxResumeAttempts attempts while it is on: the job’s last attempt never starts a continuation, because the worker fails a lease past the limit before the handler runs; it closes the chain instead. A batch job keeps its own attempt limit and needs 2 + MaxResumeAttempts attempts to get every continuation.

  • RunQuery.ContinuedFromRunId and GET /api/runs?continuedFromRunId= walk a continuation chain forward.

  • A structured tracon.workflow batch item may name a sessionId: the run’s execution session, which its run.completed webhook and its continuations carry. An agent batch item that names one fails alone.

  • RunStoreContract adds A_second_start_without_a_continuation_link_keeps_the_first_one, A_queued_workflow_row_keeps_its_workflow_session_and_attribution_when_it_starts, Query_filters_by_the_run_a_continuation_continues and Query_by_continuation_does_not_return_another_tenants_run. JobStoreContract adds A_pending_report_marks_the_item_and_counts_nothing and A_report_for_a_finished_item_changes_nothing. A third-party store needs the first and the last of these: the second start of a run keeps a stored ContinuedFromRunId, and a report for a finished item is ignored.

  • Named speech providers: AddSpeechSynthesizer(name, factory) and AddSpeechTranscriber(name, factory) register more text-to-speech and speech-to-text engines next to the default one, and ISpeechProviderResolver (registered by AddTracon()) returns one by name after the tenant’s checks — for a workflow function node, for example. SpeechProviderDescriptor and SpeechProviderKind describe a registration. A name is 1 to 64 characters of a-z, 0-9, ., _ and -; a repeated name throws at setup.

  • GET /api/voice/providers lists the registered speech providers (new in the OpenAPI document and both generated clients). POST /api/voice/speak takes an optional provider and GET /api/voice/voices an optional ?provider=; an unknown name answers 400, a request without a name when no default is registered 501.

  • Tenant credentials for speech providers: ISpeechSynthesizer and ISpeechTranscriber gain SupportsTenantCredentials (default false), SpeechRequest and SpeechTranscriptionOptions gain Credential, and ISpeechSynthesizer gains ListVoicesWithCredentialAsync, whose default refuses a credential. All are default interface members or new init properties, so an existing implementation keeps compiling.

  • SpeechAudio.ModelId and SpeechTranscript.ModelId carry the model that did the work, and IVoicePricingReader gains ForSynthesis(providerName, modelId, characters) and ForTranscription(providerName, modelId, duration) (default null). A speech call is priced by the model the provider reports, then the one the request named; the built-in client reports its model.

  • IWorkflowRunner.ValidateResponseAsync: checks an answer against the pending request before the stream opens. A default interface member, so an existing implementation keeps compiling.

  • IRetentionStore.ArchiveBatchAsync reads one batch, hands it to the archive, and deletes exactly that batch. Its default implementation keeps the old two-call behavior so an existing store compiles, and can delete rows it did not archive — override it. RetentionStoreContract adds Archive_batches_delete_only_the_rows_they_archived, A_failed_archive_write_deletes_nothing and a SeedOldRowAsync(tenantId, startedAt) hook.

  • EvalCaseDraft.Parameters: the parameter values a promoted run was bound with. EvalStoreContract adds AddCaseAsync_stores_the_parameter_values_of_a_promoted_run.

  • RunStoreContract.ClaimOrphanedRunsAsync_reports_the_kind_and_the_tree_position_on_the_claimed_record: a custom IRunStore returns Kind, ParentRunId and Depth on a claimed record, because continuation decides on them.

  • WebhookEvalSummary and WebhookEventPayload.Eval: the payload of the eval.completed webhook.

  • WebhookRunSummary.Kind: Agent or Workflow, on every run.* webhook.

  • OpenAIProviderNames.SettingsPrefix (openai) and OpenAIProviderNames.SupportedSettings (empty).

  • HarnessSettings.EnableWebSearch: turns on the model provider’s hosted web search for a harness agent.

  • TraconEndpointOptions.StreamKeepAliveInterval: the longest a streaming endpoint stays silent before it writes an SSE comment. Default 15 seconds; keep it below the shortest idle timeout between the server and its readers, and below 30 seconds while the console is in use.

  • JobErrorCodes.AttemptsExhausted (tracon.job.attempts-exhausted).

  • TraconChildRunTimeoutException (error type child_run_timeout, class Timeout), with AgentName and Timeout.

  • A harness loop can fail the run instead of returning a response no criterion accepted. LoopSettings.OnExhausted (the ceiling is reached while a criterion still asks for another iteration) and LoopSettings.OnCriterionError (a criterion throws) take a LoopOutcome: ReturnLastResponse, the default and today’s behavior, or FailRun. Exhaustion fails with TraconLoopUnsatisfiedException (error type loop_unsatisfied, new class RunErrorClass.LoopUnsatisfied = 15); a criterion error fails with the criterion’s own error. A workflow stops at that agent. With OnExhausted = FailRun the turn at the ceiling is evaluated before the run is failed - one more evaluation, which for aiJudge is one more judge call - and the LoopIterationCompleted event gains failsRun. JSON takes the names ("onExhausted": "FailRun"); an unknown name is refused with 400. A run the loop fails keeps the usage and cost of every turn it ran.

  • TraconMcpOptions.StaleToolRetention.

  • ILiveVoiceProvider.SupportsTenantCredentials and ILiveVoiceProvider.AttachSessionAsync(LiveVoiceAttachRequest), both with default implementations, plus LiveVoiceCreateRequest.Credential and LiveVoiceAttachRequest. A live voice provider that sets SupportsTenantCredentials receives the tenant’s own key on both calls of a session; one that does not is refused for a tenant with its own binding.

  • ToolApprovalRule.SourceFingerprint.

  • A workflow can declare run parameters, and a run carries values to every agent step. WorkflowDefinition.Parameters, WorkflowDescriptor.Parameters, WorkflowSaveRequest.Parameters, WorkflowRunRequest.Parameters, WorkflowRunHttpRequest.Parameters and GetRunParameters() (an extension on IWorkflowContext, for a function node). A step receives the values its own schema names; an invalid value is a 400 before the stream opens, and a participant whose required parameter nothing supplies fails the run before its first model call. A resume or an answer to a waiting run reads the values back, and is refused when the workflow now declares parameters and the run recorded none.

  • A queued agent run (Prefer: respond-async) accepts parameters and documents, and a batch item can be a structured object ({"message", "parameters", "documents"}) for tracon.agent-batch and tracon.workflow. jobs.payload and the item inputs are not content-protected and now hold these values in plain text.

  • RunInputRecord.Parameters, ProtectedColumn.RunInputParameters (10) and a nullable run_inputs.parameters column on all three storage providers (PostgreSQL 0056, SQL Server 0044, SQLite 0043). null means nothing was recorded and {} means the run had no values; the retention policy keeps the row of a run waiting for a human.

  • RunReplayRequest.Parameters: values laid over the ones the source run was bound with.

  • A workflow function can take settings, so one function serves several workflows with a different configuration in each. AddWorkflowFunction<TInput, TOutput, TSettings> (two overloads: a plain one and one that takes an Action<WorkflowFunctionOptions> for the description and the retry policy), WorkflowNodeReference.Settings (a JSON object, or null), WorkflowFunctionDescriptor.SettingsType and SettingsSchema, WorkflowFunctionResponse.SettingsSchema (GET /api/workflows/functions) and TraconWorkflowOptions.MaxNodeSettingsLength (16 KiB). A save reads the settings into TSettings: an unknown field, a missing required member, a value of the wrong type and a null where the member does not allow one are a 400 that names the node and the member, as are settings on an agent node, on a function that declares none, or that are not an object. The read repeats when a run starts, so settings saved against an older shape fail the run by name before any node runs. A change applies to the next run, and an older checkpoint stays usable. Settings are stored in the definition and the audit trail: never put a secret in them. The settings member of a stored node is also part of the workflow list and the generated clients.

  • The console builds the chain of a Sequential workflow: add an agent or a function (from GET /api/workflows/functions), reorder, remove, and edit a function step’s settings as JSON with the function’s fields listed under it. The editor writes nodes when the chain holds a function and agentNames otherwise, tells you when a save changes that, and warns that a run waiting for input cannot be resumed after a change of pattern, steps or order. The server accepts both forms, so a definition saved over HTTP in the other form keeps loading.

  • AddLoopEvaluator(kind, Func<IServiceProvider, LoopEvaluator> factory): a loop stop criterion created from the application’s root services. The factory runs once, on the first compilation of an agent that names the kind, so it can take IAgentCatalog; a factory that throws is not cached and fails that compilation with the kind named. Each AddLoopEvaluator overload has a variant with a description, which the console shows next to the kind.

  • GET /api/loop-criteria (LoopCriterionCatalogResponse, LoopCriterionKindResponse, LoopJudgeModelResponse, in the OpenAPI document and both generated clients): the built-in and code-registered loop criterion kinds with their descriptions, and the aiJudge judge model’s provider and model, or null. Reader role, AgentsRead scope. Listing runs no factory.

  • The console’s agent editor edits the harness loop: criteria (add, remove, reorder, per-kind fields), the iteration ceiling, the fresh-context switch and both outcomes. A kind the host does not list is kept and marked. The version diff shows the loop.

  • A response the model stopped at its output token limit is now its own outcome. A structured (Json or JsonSchema) response fails the run with error type output_truncated and the new RunErrorClass.OutputTruncated (TraconOutputTruncatedException in-process); no structured-response repair turn runs, and the StructuredResponseRejected event carries repairSkippedTruncated when one was left. A plain-text response completes and the run writes a tracon.output.truncated event (RunEventCustomTypes.OutputTruncated). Only the turn that ends the run decides; a tool call cut off in an intermediate turn does not. The tokens of the cut-off call count in the run’s usage and cost. The new enum member is in the OpenAPI document and both generated clients.

  • Anthropic provider settings anthropic.effort (output_config.effort: low, medium, high, xhigh, max), anthropic.thinking.mode (enabled or adaptive), anthropic.promptCaching.ttl (5m or 1h) and anthropic.promptCaching.placement (request or system, which marks the end of the agent’s instructions so requests with different documents share the cached prefix). A wrong value, a cache setting without anthropic.promptCaching = true, and a thinking budget with adaptive mode are errors. Nothing changes for an agent that sets none of them.

  • ModelDescriptor.SupportsSampling, read from a provider’s Models catalog entry. When it is false, a definition that sets Temperature or TopP (on the binding or a fallback link’s model) fails to compile, and POST /api/agents/validate reports sampling_unsupported at model.temperature or model.topP. GET /api/models returns supportsSampling; null means unknown and checks nothing.

  • POST /api/agents/validate warns with output_truncation_risk when a binding leaves MaxOutputTokens empty, the provider sends a lower default limit, and the agent asks for a structured response or high reasoning effort. A provider tells its default through ModelOutputLimit, returned from the chat client’s GetService; the Anthropic provider does.

  • ModelProviderSettingsContract.InvalidSettingValue and the scenario A_supported_setting_with_an_invalid_value_is_rejected: a provider that declares a key with a closed set of values proves that a wrong value is rejected and named. It is skipped when the property is left null.

  • A workflow function node can report what it spent beyond tokens. The new extension IWorkflowContext.ReportUsageAsync(ToolCallUsage) (in Tracon.Workflows) records one item: a text-to-speech character, a render second, a minute of audio. It returns ValueTask<bool> and does not throw except for a null argument; it returns false outside a Tracon workflow run, for an invalid item (an empty Unit, a negative quantity or amount, or a value beyond what its column holds: a unit of 64 characters, a currency of 16, a provider of 100, a model of 200, a quantity above 9,999,999,999, an amount above 1,000,000,000) and once the run holds 1,000 items. An item is written at once and is not cancelled with the run, so a cancelled or failed run keeps what its node had spent, and a retried node keeps the items of its failed attempts. The item names the node that reported it; a custom executor in a graph built in code reports with no node name. An item reported after the run closed (a node that outlived its timeout) stays in the run’s detail and does not change the total, the quota entry or the webhook.

  • A run has a non-token cost, a total of its own beside the token cost. RunNonTokenCost (Amount, Currency, IsEstimated, UnsummedItemCount) is on RunRecord.NonTokenCost (the run itself) and RunRecord.TreeNonTokenCost (the run and every run below it, the run counted once), and RunCompletion.NonTokenCost carries it to the store when a run closes. null means the run reported nothing, which is not zero; an Amount of null with a positive UnsummedItemCount means items were reported and none could be priced. One rule decides what adds: an item adds only when it has an amount in Tracon:Pricing:Currency (an item with no currency label is in it). Items with no amount, or in another currency, are listed and counted in UnsummedItemCount, never added or converted, and a quantity is never added across units. An estimated item adds and raises IsEstimated. Tracon never prices a node’s work: the node reports the amount it computed. RunSpend.Total(RunCost?, RunNonTokenCost?) and RunSpend.Total(RunTreeCost?, RunNonTokenCost?) are the one place the token cost and the reported amount are added; amounts in two currencies are never added.

  • ToolCallUsage.Provider and ToolCallUsage.Model, names for the reader of the record that take no part in a total. The built-in speak, transcribe and generate_image tools fill them. A tool still reports with TraconToolUsage.Report, and its measurement now also joins the run’s non-token total the moment the tool reports it.

  • GET /api/runs/{runId}/usage (Reader role, runs:read scope) lists the items a run’s function nodes reported, in the order reported. It lists the run’s own items, not its children’s; a tool’s measurement stays on GET /api/runs/{runId}/tools. An unknown run, another tenant’s run and a denied run all answer the same 404. GET /api/runs and GET /api/runs/{runId} carry nonTokenCost and treeNonTokenCost. All of it is new in the OpenAPI document and both generated clients (TraconListRunNonTokenUsageAsync, RunNonTokenCost, NonTokenUsageRecord).

  • NonTokenUsageRecord and the IRunStore members RecordNonTokenUsageAsync and ListNonTokenUsageAsync. Three migrations add a run_non_token_usage table (its rows are deleted with their run), four snapshot columns on runs (non_token_cost, non_token_currency, non_token_estimated, non_token_unsummed) and usage_provider and usage_model on tool_invocations: PostgreSQL 0058, SQL Server 0046 and SQLite 0045. Existing rows keep NULL: nobody reported a number for them.

  • The console shows a tool call’s measurement (quantity and unit, the amount or “unpriced”, “estimated”, provider and model) and, for a run that reported anything, a “Non-token usage” panel ending in “Reported in this run”, “Reported in the whole tree” and “N not added”.

  • The tracon.recording.stage tag has a new value, non_token_usage: an item a function node reported could not be recorded. Alert rules that list the stages need it.

  • Tracon.Testing.Contracts.Xunit: NonTokenUsageContract, and new scenarios in RunStoreContract and ToolInvocationContract for the snapshot, the tree total and a measurement’s provider and model.

  • A stored workflow keeps its version history. Every save appends a version and a rollback appends the old content as a new one; nothing is rewritten. GET /api/workflows/{name}/versions (Reader) lists the full snapshots, newest first, GET /api/workflows/{name}/versions/{a}/diff/{b} returns two of them as left and right, and POST /api/workflows/{name}/rollback (Admin, body WorkflowRollbackRequest) rolls back. A rollback is judged like a save: a version that names a function that is no longer registered, settings that no longer fit, or an agent version that no longer exists answers 400 and writes nothing. An unknown version is 404, a workflow defined in code is 409 (its versions read 404). The audit trail records workflow.rollback with the version it came from and the one it wrote. A version number is never reused: deleting a workflow keeps its history and a workflow saved again under the same name continues the numbering. New in the OpenAPI document and both generated clients (TraconListWorkflowVersionsAsync, TraconGetWorkflowVersionDiffAsync, TraconRollbackWorkflowAsync, WorkflowVersionDiffResponse, WorkflowRollbackRequest).

  • IWorkflowDefinitionStore.GetVersionAsync, ListVersionsAsync and RollbackAsync. Three migrations add the workflow_definition_versions table (no foreign key to workflows): PostgreSQL 0059, SQL Server 0047 and SQLite 0046. Each stored workflow starts its history at the version it has today; the versions it had before were never kept. A save writes the current row and its history entry in one transaction.

  • A workflow run’s row records the version of the definition it was compiled from (RunRecord.AgentVersion of the workflow’s root row; null for a workflow defined in code and for a row written before this release). A resume or a response is stamped with the version it ran.

  • definitionVersion on POST /api/workflows/runs/{runId}/resume and .../respond (WorkflowResumeHttpRequest.DefinitionVersion, WorkflowRespondHttpRequest.DefinitionVersion, WorkflowResumeRequest.DefinitionVersion, WorkflowRespondRequest.DefinitionVersion) names the stored version to continue with. A version the workflow does not have is 400 before a stream opens or a job is queued, and throws before any run row is opened when the engine is called directly; a run is never continued with the current definition in its place. A queued resume and a queued response carry it.

  • WorkflowNodeReference.AgentVersion pins an agent step to one version of that agent. The step runs that version’s instructions, model and tools while the agent is edited, and its child run row records the version. Only an agent node in the nodes form can pin; a function node, a value below 1, an agent defined in code and a version the agent does not have are refused with 400 when the workflow is saved and when it is compiled. The pin is not part of the step’s identity, so changing it does not make a waiting run’s checkpoint unusable. The agent’s own sub-agents and skills still resolve their current version.

  • The console lists a workflow’s versions, compares two of them and rolls back, and an agent step in the chain editor can pin a version of a stored agent.

  • Tracon.Testing.Contracts.Xunit: new scenarios in WorkflowDefinitionStoreContract for the history, the numbering after a delete, rollback, concurrent saves, tenant isolation, a pin’s round trip and cancellation, and the optional SupportsRawHistory, ExecuteRawAsync, QualifiedWorkflowHistoryTable and RawNowExpression hooks a SQL provider overrides to prove that a failed history write leaves the current definition as it was.

  • The agent step of a workflow, in a stored chain and in a graph built in code, now asks the agent for its whole answer instead of a stream. The step never forwarded a delta to the workflow’s feed, so the stream bought nothing, and the streaming path has no structured-response repair. Behavior change: a step whose model answers JSON that does not fit the agent’s schema is asked again up to Tracon:StructuredResponse:MaxRepairAttempts (before, the broken answer reached the next node), and the step’s own runs row has IsStreaming = false and a message.completed event beside the message.delta it already had. Its tokens, cost and input are recorded as before. A loop agent still hands the next node its final iteration only.

  • A workflow function node no longer starts once the call tree’s cost or time budget is spent. Until now the budget refused a model call and a new agent step but let a function node run and do its paid work (speech, a render, a GPU minute), and a chain of function nodes only never stopped at all. Behavior change: with Tracon:AgentGraph:MaxTotalCost or Tracon:AgentGraph:MaxDuration set, the next function node does not start once the tree has passed either; the run ends Failed with run_budget_exceeded (class QuotaExceeded), the message names the node and the setting to raise, and no later node runs. Each attempt of a retried node is checked, and the refusal is never retried, whatever the registered IRunErrorClassifier says. MaxTotalTokens does not stop a node (it is on by default and a function node spends no tokens), and a host that sets neither ceiling is not affected. A node that is already running is never stopped, so a tree can pass its ceiling by as much as its last node spends. A node that spends nothing and has to run afterwards, such as a step that stores what the budget has just paid for, sets the new WorkflowFunctionOptions.StartsWhenBudgetIsSpent to true in its registration; an operator cannot set it in a definition. A run built in code with AddWorkflow and its own executors is not gated: read TraconRunContext.Current?.Budget there.

  • POST /api/workflows/runs/{runId}/respond changes in the default Branch mode too. The run an answer starts now has its row opened (Running for a streamed answer, Queued for a queued one) before the answer is recorded and before the stream starts, and the answer’s record is written before the run is handed over. Behavior change: when the record cannot be written (workflow_responses unavailable), the answer is refused with 500 and the opened run is closed Failed with the error type answer_not_recorded and no events; before, the answer was not recorded at all. A run store that cannot open the row refuses the streamed answer with 500 too; before, the engine’s run writer logged that failure and the run went on unrecorded. Every run an answer starts now carries WorkflowResponseApplied at sequence 1, so its later events are numbered one higher than before.

  • Behavior change: the in-memory job store (the default IJobStore) refuses a job whose id is already queued, as the PostgreSQL, SQL Server and SQLite stores’ primary key does; before, it replaced the queued job, which could set a leased job back to Pending. The IJobStore.EnqueueAsync documentation now states the refusal, and JobStoreContract checks it with a new case. A queued run whose enqueue is refused because its job is already there (another call wrote it) is no longer closed as Failed; the call answers with the job that is there.

  • AddWorkflow(builder, name, factory, string? description = null) is now two overloads, AddWorkflow(builder, name, factory) and AddWorkflow(builder, name, factory, string? description), beside the new one that takes Action<WorkflowRegistrationOptions>. Every call compiles as before except one that passes a literal null as the third argument, which is now ambiguous; leave the argument out instead.

  • Behavior change: a configuration entry Tracon cannot read now stops the application at startup instead of being dropped without a word. Four settings change:

    • Tracon:Quotas:ThresholdPercents and Tracon:Webhooks:RetryDelays: an entry that is not a number (a TimeSpan for the delays), or that is zero or below, was skipped, so the list came out shorter than written. It now fails startup and the message names the key and quotes the entry (ThresholdPercents[1] is '9O').
    • Tracon:ContentProtection:Columns: an entry that is not one ProtectedColumn name — a misspelling, an empty entry, an undefined number, or two names joined by a comma, which was read as a third column — was skipped, and the column you meant stayed in plain text. It now fails startup and the message quotes the entry.
    • Tracon:ContentProtection:Keys: an entry with an empty configuration key name was skipped. It now fails startup and the message names the key id.
    • Tracon:ContentGuard:Pattern:MaskedPii: one misspelled name ("Emial,CreditCard") turned PII masking off for the whole list, and a number with an undefined bit was accepted. It now fails startup and the message quotes the value. An empty value still means no pattern.

    The Columns and Keys checks run while content protection is off too: an old encrypted row is still read through Keys. A value that code sets for the same member after binding replaces the configured one and clears the error. An undefined ProtectedColumn or PiiPatterns value set in code (for example options.MaskedPii = (PiiPatterns)64 in AddPatternContentGuard) now fails startup as well.

  • Behavior change: a workflow run that waits to be resumed keeps the definition version it recorded. Before, resume and respond compiled the current definition: an edit made while the run waited changed its settings, timeouts and parameter schema without notice, and a structural edit (a step added, removed or reordered, the pattern or the stored form changed) made the run impossible to resume. Now such an edit changes nothing for that run. To carry a run over a fix, name the edited version in definitionVersion; a version whose graph differs from the checkpoint’s fails the run with “the graph’s structure differs”, as before. A run whose row recorded no version (written before this release) resumes with the current definition, and a resume of a workflow that was deleted is still refused.

  • Breaking for implementers (preview): IWorkflowDefinitionStore has three more members (GetVersionAsync, ListVersionsAsync, RollbackAsync). A store of your own has to keep the history; a default implementation would have dropped every pinned step the first time the workflow was edited. The in-memory store and the three SQL stores implement them, and WorkflowDefinitionStoreContract runs against yours.

  • The console writes the nodes form of a chain when it has a function node or a pinned agent step (it was: a function node). A chain of agents alone with no pin is still saved as agentNames. Pinning the first step of such a chain changes the stored form, which is a graph change: the editor warns, and a run that waits keeps resuming the version it recorded.

  • Behavior change: skill script results read differently. A timed-out script now reports The script timed out and was stopped. (was … and the process tree was terminated.); a script whose executor did not return within Timeout plus five seconds reports that it may still be running, and its tool call error is Timed out; the script executor did not return in time. The truncation notice is now one line at the end of the result, [Tracon: output truncated at the N-byte limit per stream.], added only by the runner, which also caps each stream again. An executor that throws fails the call with only the exception type in the tool call record and the model’s view; the message goes to the server log.

  • Behavior change: TraconSkillScriptOptions.MaxScriptContentLength now limits a script file under SkillRoots too. A file larger than the limit, an empty file, or anything that is not a regular file (a FIFO, a link to a device) is refused from its metadata before it is opened and is written to the audit trail as script.denied. Before, a FIFO blocked the call while it held a concurrency slot, and a link to /dev/zero was read without end. A stored script whose name is not a plain file name is now refused the same way, before the script.run entry.

  • Behavior change: TraconSkillScriptOptions.Timeout accepts at most about 49.7 days minus five seconds; a larger value fails at startup, because the runner’s wait limit adds five seconds to it.

  • Tracon.Testing.Contracts.Xunit: ModelProviderSettingsContract’s invalid-value case now also fails when the refusal message repeats a text InvalidSettingValue. Behavior change: a provider that echoes the rejected value fails the case; name the key and the accepted values instead.

  • ISpeechProviderResolver gains GetDialogueSynthesizerAsync. The member has no default implementation: a resolver of your own must implement the dialogue gate.

  • Behavior change: POST /api/jobs/{id}/cancel now reaches a running handler. The worker reads each running job’s status every Tracon:Scheduling:PollInterval and cancels the handler’s token when it finds the job cancelled or gone; before, a running job ran on until its handler called JobContext.IsCancelledAsync. This costs one store read per running job per poll interval. A job cancelled this way stays Cancelled.

  • Source break: builder.AddWorkflowFunction<TInput, TOutput>(name, factory, null) no longer compiles (CS0121): a null third argument fits both the description parameter and the new Action<WorkflowFunctionOptions> overload. Name the argument (description: null) or leave it out.

  • The generated C# client TraconApiClient changes three method signatures. TraconListRunsAsync gains a continuedFromRunId parameter before skip and take, TraconVoiceListAsync gains a provider parameter and TraconEraseDataSubjectAsync a force parameter before the cancellation token. The new parameters are optional, so a call that names its arguments compiles unchanged. A call that passes skip, take or the cancellation token by position no longer compiles; name the argument. Code compiled against the previous package must be rebuilt.

  • Behavior change: the streamed workflow endpoints now check their input before the stream opens. POST /api/workflows/{name}/run with an invalid sessionId, resume of an unknown run or checkpoint, and respond to a run that does not wait for input answered 200 and reported the error inside the event stream; an unknown checkpoint also opened a new Failed run row. They now answer 400 (invalid session id, unknown checkpoint, a run that does not wait) or 404 (unknown run, or a run of another tenant) as problem details, and open no run.

  • Behavior change: the names functions (workflows) and handler-keys (schedules) are reserved. GET /api/workflows/functions and GET /api/schedules/handler-keys are matched before {name}, so a definition saved under one of these names could never be read back. Saving one now answers 400, and AddWorkflow throws ArgumentException. The check ignores case.

  • The console’s tenant provider panel offers speech providers next to the model providers, by the provider name a policy and a binding use. A name saved in a policy that no provider reports any more stays listed and can be removed; before, it was invisible and sent back on every save.

  • IWorkflowFunctionCatalog gains ValidateSettings(string name, JsonElement? settings), which returns a message that names the function and the problem, or null. An implementation of the interface outside Tracon must add it; the registry AddWorkflowFunction builds is the only implementation Tracon ships.

  • Tracon.Testing.Contracts.Xunit adds two WorkflowDefinitionStoreContract cases: a function node’s Settings survive the round trip, and saving the definition again without them clears them. A custom IWorkflowDefinitionStore that rebuilds a node from its own columns and drops the field now fails them.

  • The generated C# client documents the 41 nullable object properties whose description was lost on the way from the OpenAPI document (for example AgentDefinition.Harness).

  • Every endpoint that starts a run now refuses an attribution that breaks a label limit with 400 before the run starts, as the agent run endpoint already did: workflow run, resume and respond, run replay, the inbound trigger endpoint, and /v1/chat/completions and /v1/responses (in the OpenAI error shape, type: invalid_run_attribution). These endpoints started the run with no user and no labels, and answered 200.

  • A run that spends tokens on more than one model (a compaction summarizer or an aiJudge judge on a model other than the agent’s) prices each model’s share at that model’s price, and cost is the sum. The three *PricePerMillionTokens fields of such a run are null: one rate cannot describe the total. A model with no price adds nothing to the cost and is not priced at the agent’s rate. The cost is Unknown only when no model of the run has a price. POST /api/stats/recalculate-costs does not re-price such a run when any of its models had a price. A run whose summarizer or judge runs on the agent’s own model is unchanged.

  • UseVoice() and UseOpenAILive() now validate their settings when the application starts. Both validators already said so, but neither registration asked for it, so an invalid value — a missing ElevenLabs key, a headerless OutputFormat, a zero MaxAppendCharacters, a missing OpenAI key — surfaced at the first voice request or live session. A host registered with such a value now fails at startup, and the message names the setting.

  • The OpenAI provider now rejects every ModelBinding.ProviderSettings key, as Azure OpenAI already did. The rule covers openai, openai-responses and every UseOpenAICompatible() registration; their settings prefix is openai (OpenAIProviderNames.SettingsPrefix) and OpenAIProviderNames.SupportedSettings is empty. Before, the provider never read the dictionary, so a key, for example anthropic.promptCaching left over after a switch from Anthropic, was ignored silently: the agent saved and ran without the behavior its author wrote. Now POST /api/agents/validate reports invalid_setting at model.providerSettings, saving the definition returns 400, and a stored or code-defined agent with such a key fails compilation. Remove the key with PUT /api/agents/{name} or in code. The console does not edit providerSettings and keeps the stored value, so a console save of such an agent fails until the key is removed through the API.

  • ModelProviderSettingsContract.SupportedSetting (Tracon.Testing.Contracts.Xunit) is now virtual and returns KeyValuePair<string, JsonElement>?, default null. Derive the contract for every provider; a provider that supports no key leaves it null, and the two scenarios that need an accepted key report as skipped. An existing override fails with CS0508; add ? to its return type.

  • A harness agent no longer gets the provider’s hosted web search by default. Set HarnessSettings.EnableWebSearch to turn it on; the search runs at the provider and is billed by it, and the Chat Completions surface accepts it only for search models. A definition that sets both EnableWebSearch and DisableWebSearch is refused when it is compiled. The console’s harness section shows “Enable web search” in place of “Disable web search”.

  • A failed workflow run’s error.type is the same stable identity an agent run records - loop_unsatisfied, run_budget_exceeded, upstream_error - instead of the exception’s CLR name, and the run now carries an error class and fingerprint. A consumer that matched a CLR name such as Tracon.TraconLoopUnsatisfiedException on a workflow row matches the identity instead.

  • A non-streaming harness loop returns the final iteration’s answer; its usage stays the sum of every iteration. A streaming loop still streams every iteration. Each iteration remains on the run as a LoopIterationCompleted event.

  • A code workflow built with GetWorkflowAgent fails at a step that cannot answer, as the defined workflows do (see Fixed).

  • A job’s lease is renewed at a third of Scheduling:LeaseDuration instead of half, so the longest accepted lease is about 149 days.

  • IAgentDecorator.Decorate receives the descriptor of the definition version the agent was compiled from - the pinned version on a pinned resolve.

  • Tracon.Testing.Contracts.Xunit adds two JobStoreContract cases: CompleteAsync and ReleaseForRetryAsync leave a cancelled or finished job unchanged. A custom IJobStore that overwrites a final status now fails them.

  • CompiledAgentCache holds one entry per tenant, agent name, definition and culture. A compile under a new dependency fingerprint (skills, egress policy, MCP tool bindings) replaces the entry instead of adding one, so the cache no longer grows when a remote server edits its tools; Count counts these entries.

  • The AddGeneratedTools() extension that the [TraconTool] generator writes into your assembly is now internal. It was public, so a host that referenced a class library with tools saw the library’s copy next to its own, and AddGeneratedTools() failed with CS0121 (or with TRC0005 in a host without tools). A library hands its tools to a host through its own public extension that calls AddGeneratedTools() from inside; a host that called a library’s generated extension directly calls that library extension instead. The TRC0005 message now says so.

  • Tracon.Testing.Contracts.Xunit adds two ToolApprovalRuleStoreContract cases: SourceFingerprint round-trips, and two rules that differ only in it are two rules. A custom IToolApprovalRuleStore that drops the field or leaves it out of the rule’s identity now fails them.

  • Tracon.Testing.Contracts.Xunit adds three store contract cases. ExperimentStoreContract requires that SaveAsync keep the canary policy of a draft and never write one itself. WebhookStoreContract requires that SaveSubscriptionAsync write the failure count the subscription carries. A custom store that does otherwise now fails them.

  • A replay, an automatic continuation and a resume after a tool approval no longer refuse an agent that declares parameters. They bind the values the source run recorded (a replay request can change some of them) and refuse a source that recorded none. Before, they refused every such agent. If you upgrade with an approval pending on a queued run of a parameterized agent that predates parameter recording, the decision cannot be applied and says so.

  • The batch item rule changed: an item that is a JSON object with a string message member is now read as a structured item instead of as plain text. A text item whose content is such an object is the one ambiguity; the stored item keeps no type. A malformed structured item fails alone, and the schedule endpoints refuse it when it is saved or triggered. A schedule you saved before this release that holds such an item with a member the shape does not name ({"message": "...", "user": "bob"}) is not checked again; its item fails on the next run instead of running as text.

  • An inbound trigger that targets a workflow now stores the sender’s text as {"message": "<text>"}. The body of a webhook is not yours: read as a batch item, a body such as {"message": "...", "parameters": {...}} would have let the sender choose the workflow’s parameter values. The job item’s input shows the wrapped text, and the run still gets the body as its message.

  • Online evaluation and POST /api/runs/{id}/judge skip a run whose recorded input has no messages, as they skip a run with no recorded input. A workflow run that declares parameters now has such a row (it holds the values).

  • GET /api/runs/{id}/input answers 200 with an empty message list for a workflow run that recorded parameters; it answered 404 before. The values are not returned there. A replay of a workflow run answers 400 and points to resume.

  • POST /api/agents/validate reports every loop problem with its own code (loop_criteria_empty, loop_max_iterations_invalid, loop_outcome_invalid, loop_criterion_kind_missing, loop_criterion_unknown, loop_marker_missing, loop_judge_criteria_missing, loop_judge_unconfigured, loop_judge_unavailable) and the field’s path, for example harness.loop.criteria[1].marker. Before, it reported the first loop problem only, as a path-less compilation_error. Saving such a definition returns 400 with every loop message, and a compile error lists them all.

  • An aiJudge loop criterion with a blank entry in judgeCriteria is now refused, with loop_judge_criteria_missing at the entry’s path (harness.loop.criteria[i].judgeCriteria[j]). Before, such a definition compiled, and the judge received a criterion with no text to check. Remove the blank entries; the console drops them when you leave the field.

  • A structured response cut off at the output limit no longer completes with a partial JSON document (validation off) or fails as StructuredResponseInvalid after spending every repair turn on the same limit (validation on); it fails once as OutputTruncated. A dashboard or alert rule that counted these runs under StructuredResponseInvalid needs the new class.

  • Online evaluation does not retry a judge whose model answer was cut off at the output limit. The item fails with judge_failed on the first attempt, as for a permanent 4xx; before, the job sent the same request with the same limit until its attempts ran out. Raise the judge binding’s MaxOutputTokens.

  • Source break: IRunStore gains two members, RecordNonTokenUsageAsync(NonTokenUsageRecord, CancellationToken) and ListNonTokenUsageAsync(Guid, CancellationToken). They have no default implementation, so a custom IRunStore does not compile until it adds both. A store must also keep RunCompletion.NonTokenCost on the run and return it, with the tree total, as RunRecord.NonTokenCost and TreeNonTokenCost. Derive the new NonTokenUsageContract beside RunStoreContract to prove it. The shipped stores implement both members; only a store of your own needs the change.

  • Behavior change: WebhookRunSummary.Cost on run.completed and run.failed now means the token cost and the amount the run reported beyond tokens, together. It already said “the total amount”; a run that reported nothing sends the same number as before. The new WebhookRunSummary.NonTokenCost is the reported part, absent when nothing was reported, so the token part is Cost - NonTokenCost whenever both are in the same currency. When a tree’s token cost and its reported amount are priced in different currencies, Cost stays the token cost alone and NonTokenCost is still given. A consumer that read Cost as tokens only should subtract it.

  • Behavior change: the amounts a run reported beyond tokens count against the tree budget (Tracon:AgentGraph:MaxTotalCost), a tenant’s cost quota and the cost in both webhooks. An amount that adds is recorded into the budget the moment it is reported, so the tree’s next model call is refused once the ceiling is passed, and the quota entry of an agent root and of a workflow root includes it. A tool’s measurement joins the same total. A run that reports nothing is unaffected. Still token-only on purpose: GET /api/stats, the dashboard, the tracon.run.cost metric, the comparison of two runs and the console’s Tools screen. A quota has no limit on a unit such as characters. The same ceiling also stops a function node from starting (see the entry that begins “A workflow function node no longer starts” at the top of this section).

The first security release. It closes the tenant-boundary and credential-exposure defects of the earlier previews listed under Security, and it changes the public API: every removed or changed type is named below. Read Security and Changed before you upgrade.

Every change below except the audit trail one can refuse a request or a record that a running setup accepted before. Each refusal names what to change.

  • A stored record may name only a configuration key inside its own tenant. The allowed prefix is one per installation, so a record of one tenant could name another tenant’s key and point it at an address it controls. A non-default tenant now names keys under {prefix}{tenant}: (for tenant acme, Tracon:McpSecrets:acme:GithubToken); a flat name directly under the prefix belongs to the default tenant, so a single-tenant installation changes nothing. The rule covers MCP servers, tenant provider bindings, webhook subscriptions and inbound triggers, where the record is saved (400) and where its value is resolved. Move an out-of-tenant name under the tenant’s segment.
  • /api/tenants/{tenantId}/providers, /api/tenants/{tenantId}/egress and the tenant records (/api/tenants) act on a tenant other than the caller’s own only with platform authority: an API key that also carries PlatformAdmin, the static AuthToken, or — for a claims principal while multi-tenancy is on — the new TraconPolicies.PlatformAdmin policy. A missing registration of that policy denies (403). Before, a key bound to one tenant with SecurityAdmin, or a claims-based Admin of one tenant, could rewrite another tenant’s provider binding.
  • With AllowRemoteAccess on and neither AuthToken nor an authorization policy configured, an anonymous remote request now returns 401; API keys keep working. A request that still carries X-Forwarded-For, Forwarded or X-Real-IP counts as remote, so a reverse proxy on the same machine no longer makes every caller look local. Without a token or a policy, an anonymous request with a non-loopback Host or Origin returns 403, which blocks DNS rebinding and cross-site requests; the voice WebSocket follows the same rule.
  • Creating an API key that carries the PlatformAdmin scope now needs the same platform authority as acting on another tenant. Before, a claims-based Admin of one tenant, without the TraconPolicies.PlatformAdmin policy, could create such a key and use it to reach other tenants. An API key can still only create scopes it carries itself.
  • GET /api/mcp-servers and the PUT /api/mcp-servers/{name} response no longer return stored extra header values: every header keeps its name and carries the value ***. Before, any Reader or AgentsRead API key could read a header value such as an API key and call the MCP server directly, past the tool approval gate. Webhook subscriptions (GET /api/webhooks, GET /api/webhooks/{name} and the PUT response) follow the same rule. The stored value does not change and is still sent to the target.
  • The MCP server audit trail records header names only. The values of headers whose names the secret filter does not recognize, such as Cookie or Ocp-Apim-Subscription-Key, no longer enter the audit log as plain text.
  • A script grant now pins the content it was given for. A stored script, and a script of a skill registered in code, runs only while its content still has the hash the grant carries; a change stops it until it is granted again. A grant without a script name pins the whole script set, so adding, removing or changing any script stops them all. An existing grant pins nothing and no longer authorizes a stored or code script: grant each one again. Read scripts[].contentHash or scriptSetHash from GET /api/skills/{name} and send it as expectedContentHash to POST /api/skill-script-grants (missing or malformed 400, unknown script 404, changed content 409). Scripts read from disk are not pinned and keep working; keep their directories read-only. Before, an administrator who could write skills could replace a script’s content after a security administrator had approved it, and the old grant kept authorizing the new code.
  • In a multi-tenant host, granting a stored script needs platform authority (an API key that also carries PlatformAdmin, the static AuthToken, or the TraconPolicies.PlatformAdmin policy; 403 otherwise). The script runs under the server’s own operating-system identity and can read other tenants’ data, so the grant is an installation decision, not a tenant’s.
  • A store of your own must keep SkillScriptGrant.ContentHash. A store that drops it fails the new SkillScriptGrantContract cases, and its grants refuse every stored script.
  • A credential header of an MCP server or a webhook subscription is declared by configuration key name in the new headerConfigurationKeys, and a save whose plain headers carry a credential-looking name (Authorization, Cookie, a name containing api-key, token, password or secret, or a name ending in -key) returns 400. The detail names the header and the new field and never repeats the value. Before, Authorization was the only header with a key reference, and X-Api-Key or Ocp-Apim-Subscription-Key was stored in the database as plain text. A stored row keeps working and is still sent; each connection or delivery logs a warning that names the header. Every key name follows the prefix and tenant rules above, on save (400) and again before each connection or delivery: an MCP server whose map names another tenant’s key does not connect, and such a webhook delivery is dropped.
  • A header name may appear only once across headers and headerConfigurationKeys, compared without regard to case, and must be a valid HTTP field name (400 otherwise). Turning OAuth on for an MCP server whose kept plain headers still carry Authorization returns 400: the MCP client skips its bearer token while that header is present. A webhook’s two maps together may carry at most Tracon:Webhooks:MaxExtraHeaders entries (400 instead of being cut at delivery), and headerConfigurationKeys may not name an X-Tracon-* header.
  • PUT /api/mcp-servers/{name} and PUT /api/webhooks/{name} keep a stored header map when the request leaves it out or sends null; {} removes it. Saving from the admin UI therefore keeps the headers. The side effect: a save that changes only the address sends the kept headers, and the credential values they resolve, to the new address; the server logs a warning with the header names. Send the maps, or {}, in the same save when an address moves.
  • An OperationCanceledException that an IRunAuthorizationHandler throws, but that the request itself did not cause (for example, an HttpClient timeout inside the handler), is now a failed check. A single resource answers the same 404 as a missing one. Before, it answered 500, which revealed that the resource exists.
  • HeaderConfigurationKeys on McpServerDefinition, McpServerRequest, WebhookSubscription and WebhookSaveRequest: a map from a header name to the configuration key its value is read from. The value is resolved on each connection or delivery and never stored; a response returns the map as stored. The mcp_servers and webhook_subscriptions tables gain a header_configuration_keys column (migration 0054 PostgreSQL, 0042 SQL Server, 0041 SQLite); existing rows read an empty map.
  • McpServerStoreContract.Header_configuration_keys_are_preserved, WebhookStoreContract.Header_configuration_keys_are_preserved and WebhookStoreContract.Header_names_that_differ_only_in_case_do_not_fail_the_save. A store of your own must persist the new field and return it from the save as well as from a read.
  • Tracon.UI now carries the notices of the third-party code in its embedded console: React, React DOM, scheduler, TanStack Query, openapi-fetch, and Tailwind CSS, all MIT. THIRD-PARTY-NOTICES.txt ships at the package root and inside Tracon.UI.dll. Before, the package redistributed that code without them.
  • A webhook subscription whose headers differ only in case (X-A and x-a) no longer fails the SQL save with 500, and such an MCP server no longer fails to connect.
  • The MCP OAuth authorization flow now sends the same headers as every other connection to the server, including the ones resolved from configuration.
  • A header that the MCP transport cannot add (a content header such as Content-Type, or a value with a line break) is left out with a warning. Before, the transport’s exception carried the header value into the log.
  • The TRC0201 analyzer names the header for a literal secret in a header dictionary (McpServerDefinition.Headers["X-Api-Key"]) and points at HeaderConfigurationKeys.
  • A slow or unresponsive database no longer blocks the metrics collection thread while the quota or queue-depth gauge refreshes. Before, this delayed the export of every other instrument of the meter provider.
  • The quota threshold notification cache in QuotaEnforcer no longer grows for the life of the process. Entries of closed quota periods are now dropped.
  • A failed or cancelled durable threshold claim no longer keeps a quota threshold notification silent until the end of the period. The next run in the same period retries the claim.
  • The quota admission check, the threshold accounting, the quota gauge and GET /api/quotas/usage now read only the counters of the current periods. Before, every read returned the tenant’s whole usage history, twice per run, and that history only grows.
  • GET /api/models/health no longer fails for every provider when one provider’s health check throws. The failing provider is reported Unhealthy with the exception type in detail (The health check failed (...)), and the exception is logged at Warning level. The background health refresh also continues past that provider.
  • An Azure OpenAI CredentialFactory credential that throws while it gets a token (for example DefaultAzureCredential without an Azure sign-in) now reports Unhealthy with Credential error (<exception type>) and logs the exception, instead of escaping the health check.
  • UseAzureOpenAI(IConfiguration) now binds a relative Endpoint and a Models entry without a Name, so start-up validation rejects them with the specific message, the same as the other three providers. Before, a relative endpoint was reported as missing and a nameless entry was silently dropped. An application that relied on the silent drop now fails at start-up.
  • The out-of-catalog log entry of an UseOpenAICompatible() provider now names OpenAIProviderOptions.Models instead of the Tracon:Providers:OpenAI section, which that provider does not read.
  • Two approval rules whose argument conditions differ are no longer merged into one by the SQL stores when a condition path carries the U+001E or U+001F control character. Before, saving the second rule returned the first one unchanged. Existing rules keep their stored fingerprint; no migration runs.
  • The in-memory approval rule store now treats a list value that differs only in whitespace (["eu", "us"] and ["eu","us"]) as the same condition, the same as the SQL stores. Before, it kept two rules.

These public types are now internal. None is part of a seam you implement or a call you make: the same behavior is reached through the public registration methods, options, and interfaces. The API is not frozen in the preview line, and the counts below are types, not members.

  • Tracon.PostgreSql, Tracon.SqlServer, Tracon.Sqlite: MigrationRunner. It existed once in each provider package under the same name, so an application that referenced two providers could not name it (CS0433). Resolve IMigrationApplier to apply migrations and ISqlPersistenceDiagnostics for the migration snapshot; both resolve to the same runner. The tracon migrate command is unchanged.

  • Tracon.Anthropic, Tracon.Azure, Tracon.Google, Tracon.OpenAI: AnthropicChatClientFactory, AnthropicModelCatalog, AzureOpenAIChatClientFactory, AzureOpenAIModelCatalog, GoogleChatClientFactory, GoogleModelCatalog, OpenAIChatClientFactory, OpenAIModelCatalog, and OpenAIApiSurface. The Use* registration methods and the *ProviderOptions types are the configuration surface; a factory created through FromClient could not be plugged into a registered provider anyway.

  • Tracon.Abstractions (11): ChatHistoryState, ExperimentAssignment, JobPayload, ReplayToolMismatchException, RunAttributionReader, SafeErrorText, SchemaReadyGate, ToolArgumentConditionLimits, TraconSessionStateKeys, WebhookEventPayloadJsonContext, and WorkflowCheckpointState. A store’s checkpoint listing still returns an empty JSON object ({}) as State; the documentation of IWorkflowCheckpointStore now says so.

  • Tracon.Core (69): AgentCallGraph, AgentCallGraphProblem, AgentSessionIdentity, AttachmentTypeGuard, AttachmentUriReference, AttachmentValidationResult, AuditActorContext, AuditChainHasher, AuditChainWalker, AuditPayload, AuditRecorder, AuditSecretFilter, AuthorizingAIFunction, CanaryEvaluator, ChildRunApproval, CodeAgentRegistration, ConfigurationKeyGuard, CronExpression, DefaultProviderRetryClassifier, DocumentAttachmentSummary, DocumentChannelMessageBuilder, EgressAddressPolicy, EgressAddressValidator, EgressAddressVerdict, EgressSocketGuard, InMemoryWorkflowCheckpointStore, InboundTriggerDispatchResult, InboundTriggerOutcome, InboundTriggerRateLimiter, InboundTriggerSecretResolver, InboundTriggerValidatedRequest, InboundTriggerValidationResult, InstructionCultureResolver, InstructionParameterBinder, OnlineEvalSummaryService, ProviderCredentialClientCache, QuotaPeriodCalculator, ResolvedRetentionPolicy, RetentionExecutor, RetentionPolicyResolver, RunPromotionOutcome, RunPromotionStatus, RunReplayOutcome, RunReplayPreparation, SessionBranchOutcome, SessionBranchStatus, SkillScriptExecutionResult, SkillScriptNaming, StateGenerationCount, StatePreflight, StatePreflightReport, StateSampleFailure, TextChunker, TextTrimming, ToolApprovalPolicyRegistration, ToolApprovalPolicyRegistry, ToolApprovalRuleEvaluator, TraconEvalCheckRegistration, TruncatingAIFunction, ValidatingAIFunction, VoiceAudioFormats, VoiceConnectionLease, VoiceConnectionLimiter, VoiceConversationProtocol, VoiceConversationRequest, WebhookHttpClient, WebhookUrlValidator, WebhookUrlVerdict, WorkflowDefinitionValidator.

  • Tracon.Voice: VoiceProviderNames. The provider value stays the plain string elevenlabs.

  • Tracon.Abstractions (3 more): QuotaDecision, QuotaThresholdCrossing, and SqlPersistenceRegistrationMarker. Only the SQL provider packages register the marker; a marker registered by other code made background services and approval requests wait for a migration that never ran.

  • Tracon.Core (16 more): AgentSkillCatalog, CallableAgentResolver, ContentGuardPipeline, ModelProviderCircuitBreaker, ModelProviderHealthCache, ProviderConcurrencyLimiter, QuotaEnforcer, RunSampleRequest, RunSampler, RunTraceCollector, SandboxedSkillScriptRunner, SkillScriptSupport, TenantProviderCredentialResolver, ToolApprovalPresenterRunner, TraconLoopEvaluatorRegistration, and TraconMetrics. Metrics stay available through the TraconDiagnostics.MeterName meter, loop evaluators through AddLoopEvaluator, and quotas through TraconQuotaOptions and the quota endpoints.

  • The public constructors of the service types that the container or the run pipeline builds are now internal: RunRecordingAgent, AgentDefinitionCompiler, TraconDiagnosticsCollector, ModelProviderRegistry, SandboxedSkillScriptRunner, ChildAgentInvoker, ContentGuardPipeline, RunEventWriter, AgentSessionManager, QuotaEnforcer, ModelProviderHealthCache, RunSampler, SkillScriptSupport, ModelProviderCircuitBreaker, and TraconMetrics. Each took optional parameters, so a new dependency after 1.0.0 would have needed one more overload. Resolve the service that AddTracon() registers:

    // Before (1.0.0-preview.1 / preview.2)
    var registry = new ModelProviderRegistry([provider]);
    // After: resolve the service Tracon registers
    var services = new ServiceCollection();
    services.AddTracon().AddModelProvider(provider);
    using var serviceProvider = services.BuildServiceProvider();
    var registry = serviceProvider.GetRequiredService<IModelProviderRegistry>();

    RunRecordingAgent wraps every agent that IAgentCatalog.ResolveAsync returns, and ChildAgentInvoker wraps each callable agent of a compiled definition. The run writer’s lifecycle methods (RunEventWriter.StartAsync, RunEventWriter.CompleteAsync, RunEventWriter.RecordToolInvocationAsync and RunEventWriter.CompleteLateToolInvocationAsync) are now internal too: code inside a run reads the writer from TraconRunContext.Current and keeps AppendAsync and the read-only properties.

  • Tracon.AspNetCore now depends on exact versions of its five pre-release packages: Microsoft.Agents.AI.Hosting, .Hosting.A2A, .Hosting.AspNetCore, .Hosting.OpenAI, and A2A.AspNetCore. Before, each was a lower bound, so a newer Hosting preview restored without a warning and failed at run time: in 1.22.0-preview, Microsoft.Agents.AI.Hosting.AgentSessionStore moved out of the Hosting assembly (TypeLoadException). A direct reference to another version now reports NU1608 at restore, and a transitive one NU1107. Move to a newer Hosting preview together with the Tracon release built against it.

  • TraconToolRegistration takes only the tool in its constructor. Every other setting is an init property with the same default as before, so a setting added later does not change the constructor again:

    // Before (1.0.0-preview.1 / preview.2)
    new TraconToolRegistration(fn, requiresApproval: true, maxOutputBytes: 4096);
    // After
    new TraconToolRegistration(fn) { RequiresApproval = true, MaxOutputBytes = 4096 };

    The old named arguments fail to compile with CS1739, which names the argument. AddTool and AddScopedTool with ToolRegistrationOptions do not change.

  • ITraconBuilder now declares only Services. Its 27 registration methods (Configure, AddTool, AddScopedTool, AddToolsFrom, AddAgent, AddSkill, AddAgentSource, AddAgentDecorator, AddRunJudge, AddModelProvider, AddEvalCheck, AddLoopEvaluator, RequireCustomBinding and RequireProductionProfile) are extension methods on the new TraconBuilderExtensions class, in the Tracon namespace next to AddTracon(). A new registration method therefore never changes the interface. Calling code does not change; using Tracon; is enough.

    • An assembly compiled against an earlier 1.0.0-preview.N release (1.0.0-preview.1, 1.0.0-preview.2) that creates a TraconToolRegistration or calls an ITraconBuilder method throws MissingMethodException until it is compiled again. This includes the code the [TraconTool] source generator wrote into your assembly: the exception comes from the AddGeneratedTools() call.
    • Upgrade every Tracon package together. A provider, MCP, Voice or Testing package from an earlier preview next to the new Tracon.Core fails while it registers, for example with MissingMethodException inside UseOpenAI(), before the start-up version check can run.
    • An ITraconBuilder implementation of your own needs only Services. An explicit implementation of a removed method (ITraconBuilder ITraconBuilder.AddTool(...)) fails to compile with CS0539; delete it. A public method with the same name still compiles, but it runs only when the caller holds your concrete type. Through ITraconBuilder, Tracon’s extension method runs and writes to your Services, so a test double must return a real service collection from it.
    • Every registration method now throws ArgumentNullException for a null chain (NullReferenceException before).
    • An extension method of your own with the same signature as one of these no longer loses to the interface member. Declared in a namespace nearer to the call than Tracon (for example in your own namespace MyApp), it now runs instead of Tracon’s method, with no warning. Imported at the same level as Tracon, the call is ambiguous (CS0121). Rename or remove such an extension.
  • Every Tracon package now depends on its Tracon siblings at exactly its own version ([1.0.0-preview.3]), not at that version or any later one. Upgrading one Tracon package and not the others now fails restore with NU1107, or warns NU1608 when your project references the shared package directly.

  • A host whose loaded Tracon package assemblies come from more than one release no longer starts. It stops before any hosted service starts, with a TraconException that lists each Tracon assembly and its version. Before, it started and failed later, at the first call into a member the other release no longer has. Every Tracon library package is compared except Tracon.Client, which talks to a server over HTTP and may call another release by design. The check has no setting to turn it off. A process that never starts a host (a bare BuildServiceProvider(), or a test project that runs the store contracts) does not run it.

  • A project created with dotnet new tracon-api treats NU1608 as an error, so such a graph stops at restore. The template’s project file explains the line; remove it to keep NU1608 a warning.

  • Upgrade every Tracon package together, Tracon.Testing.Contracts.Xunit included. Measured against 1.0.0-preview.2, these calls fail at run time when a 1.0.0-preview.2 package runs next to a newer sibling: Tracon.AspNetCore calls TenantProviderCredentialResolver.ValidatePrefix (a tenant provider binding PUT) and InboundTriggerSecretResolver.ValidatePrefix (a trigger save), which no longer exist (MissingMethodException), and ConfigurationKeyGuard.RequirePrefix (an MCP server or webhook save), which is no longer reachable (MethodAccessException); Tracon.Mcp calls ConfigurationKeyGuard.RequirePrefix when it opens an MCP transport; and Tracon.Testing.Contracts.Xunit calls JobPayload.ExtractItems (two JobScheduleStoreContract cases) and WorkflowCheckpointState.IsOmitted (one WorkflowCheckpointStoreContract case), which are now internal (MethodAccessException). The published 1.0.0-preview.1 and 1.0.0-preview.2 packages accept any newer sibling, so restore does not stop that graph: a host stops at start-up, and a test project that runs the store contracts without a host fails at those calls.

  • QuotaUsageQuery has a new PeriodStarts filter: for each period, the first day whose counters to return, computed by the caller from the configured time zone. Without it, IQuotaStore.GetUsageAsync returns every period’s counters, and its documentation now says so. A custom IQuotaStore must apply the filter; QuotaStoreContract has four new scenarios for it.

  • An interval that drives a background timer now fails startup validation when it is outside 1 millisecond to about 49.7 days: Approvals:ScanInterval, Canary:ScanInterval, RunReconciliation:HeartbeatInterval and ScanInterval, Scheduling:PollInterval, Health:BackgroundInterval and Mcp:RefreshInterval. Scheduling:LeaseDuration may be at most about 99 days and SingletonExecution:LeaseDuration about 149 days, because each lease renews at a fraction of its length. Each of these checks applies only while its feature is on. Retention:BatchDelay may be zero and is always held to at most about 49.7 days. Before, such a value let the host report that it had started and then stopped it; approval expiration is on by default, so Approvals:ScanInterval=00:00:00 did this to a default setup.

  • The tracon.quota.usage, tracon.quota.limit and tracon.job.queue.depth gauges no longer read the database inside the metrics collection callback. When a gauge is enabled, a background service reads the values once at startup and then every QuotaUsageRefreshInterval / JobQueueDepthRefreshInterval. A collection only reads the last result. The first collection after startup can be empty, and a value can be up to one interval old.

  • EnableQuotaUsageGauge, EnableJobQueueDepthGauge and their refresh intervals are now read at host start. Turning a gauge on, or changing its interval, takes effect at the next start.

  • An enabled gauge whose refresh interval is outside 1 millisecond to about 49.7 days now fails startup validation. Before, zero was accepted and meant a refresh on every collection.

  • A PUT /api/mcp-servers/{name} or PUT /api/webhooks/{name} with a header value of *** is rejected with 400. A client that reads, edits and saves a record must send the real value of every header again.

  • When an IRunAuthorizationHandler throws, the request is still denied (fail-closed), and Tracon now writes one Error log line in the Tracon.RunAuthorization category. The line holds the handler type, the access kind, the tenant, agent, run and session ids, and the exception. A 403 now says The run authorization check failed. Retry the request. instead of claiming that the handler denied the call. This covers run starts, the session list, the run, attachment and approval lists, replay, and workflow resume and respond. A 404 does not change. The exception text never enters the response.

  • A session management policy (TraconSessionOwnershipOptions.ManagementPolicy) whose policy provider or requirement handler throws is now logged as an Error in the Tracon.SessionOwnership category, and the caller still gets the narrowed result. An unregistered policy writes no log line. An IRunAttributionContext that throws during an authorization check is logged once per request, and the caller is treated as having no identity.

  • An IAuthorizationPolicyProvider that throws while MapTracon resolves a role policy is now logged as a Warning in the Tracon.RolePolicies category. The role is still treated as not registered. With RequireRolePolicies on, the startup exception carries the provider’s exception as InnerException.

  • SandboxedSkillScriptRunner.RunStoredScriptAsync takes the skill and the script name (RunStoredScriptAsync(AgentSkillDefinition skill, string scriptName, JsonElement? arguments, CancellationToken cancellationToken)), so the runner compares the grant with the content it is about to run. The (string skillName, AgentSkillScriptDefinition script, ...) overload is gone.

  • GET /api/skills/{name} resolves the name the way the runtime does: a skill registered in code wins over a stored one with the same name, and the new origin field says which one was returned. A name that exists only in code now returns the skill instead of 404. Each script carries the new contentHash and the skill the new scriptSetHash; both are computed, and a value sent in a request is ignored.

  • PUT /api/skills/{name} answers 409 for a name registered in code with AddSkill, the same rule as a code-defined agent: the code skill wins the name, so a stored skill under it never ran. Before, the save succeeded and wrote a record that never ran. DELETE still removes such a stored copy and answers 409 when the name has none. The console opens a code skill read only.

  • The in-memory script grant store keeps a revoked grant, with revokedAt set, the same as the SQL stores. Before, it deleted the row, so GET /api/skill-script-grants lost the record of who granted what.

  • TenantProviderCredentialResolver takes IOptions<TraconOptions> as a third constructor argument, and ValidatePrefix(string) is replaced by ValidateKeyName(string tenantId, string configurationKeyName). The old name checked only the prefix; the rename keeps a caller from compiling against the weaker rule unchanged.

  • ToolApprovalRuleStoreContract (in Tracon.Testing.Contracts.Xunit) has two new cases. A store of your own must keep two condition sets apart even when a path carries a control character, and must treat a list value that differs only in whitespace between its elements as the same condition. The identity rule is now written on IToolApprovalRuleStore.AddAsync. A store that compares a list value’s raw JSON text fails the second case.

  • The out-of-catalog log entry of the OpenAI, Anthropic, and Google providers now has one shared wording (Use the <key> setting to add the model to the catalog.). The Gemini health detail for an unparsable model list is now The response is not valid JSON., the same as the other providers.

  • McpServerDefinition.AuthorizationConfigurationKey and McpServerRequest.AuthorizationConfigurationKey (authorizationConfigurationKey on the wire). They still save, resolve and read back, and are removed in 1.0.0. Use HeaderConfigurationKeys["Authorization"]; a record that names Authorization in both, or in the new map while OAuth is on, returns 400. Code that sets the field compiles with warning CS0618.
  • QuotaUsageQuery.AsOf. No store ever applied it, and a store cannot turn an instant into a period without the configured time zone. Use QuotaUsageQuery.PeriodStarts.
  • The net8.0 and net9.0 targets. Microsoft support for .NET 8 and .NET 9 ends on 2026-11-10, and the first Tracon release after that date drops both targets from every package; net10.0 stays. An urgent security release may still carry them. Move an application that targets net8.0 or net9.0 to net10.0 before you take that release.

A version section is not written ahead of time. At tag time this heading is renamed to the version and the date it shipped on, and a fresh empty ## [Unreleased] is opened above it, so a section always names artifacts that actually exist. Until then the release rehearsal and the GitHub release body both read the notes from here.

The consumer-entry repair release. It keeps the same public API as preview.1 and corrects the package, documentation, and registry paths a new consumer meets first.

  • Tracon.Templates now stamps its own resolved package version into generated projects. dotnet new tracon-api therefore restores the published preview without a hidden --TraconVersion override. The override remains available and is now visible in template help.
  • The root README agent-catalog example now calls the shipped ResolveAsync overload with its required culture and cancellation arguments.
  • Package descriptions, package READMEs, API counts, target-framework claims, extension samples, and console screenshots now match the shipped artifacts.
  • NuGet-rendered package documentation no longer relies on Mermaid rendering.
  • tracon --version and tracon -v report the informational package version.
  • The TypeScript client README documents all seven SSE operations and its exported readSse/SseDecoder helpers.
  • Regression gates now verify the packed template’s default version, rendered GitHub edit links, package README operation counts, shipped-language scope, and console screenshot freshness against UI source.
  • Preview npm releases use the next dist-tag, so install previews with npm install @tracon/client@next. npm does not let the latest tag be removed, and a package’s first publish binds latest to it, so latest tracks the newest preview until the first stable release claims it.
  • GitHub repository metadata, Discussions, preview release classification, and public documentation links now describe the published product.

The first published version: twenty packages on one version line.

The public API is not frozen: PublicAPI.Shipped.txt is empty in every package, and the surface may still be reduced before 1.0.

  • RequireProductionProfile() on the Tracon chain. AddTracon() brings every security-sensitive switch up permissive, so a deployment can reach production having never separated tenants, never decided who owns a session and never registered a content guard — in silence. This call refuses to start such a host. It changes no setting and secures nothing by itself: it sets no value, chooses no policy and turns nothing on; the only thing it does is turn a skipped decision into a startup failure. Six decisions are asked about — tenant separation, session ownership, at-rest content protection, content inspection, request rate limiting and retention — and each is either answered by turning the feature on or accepted by name with profile.Accept(TraconProductionRisk.SingleTenant). There is no way to accept all six at once, and every accepted risk is logged at information level, by name, on each host start. It is off by default: a host that never calls it behaves exactly as before and resolves nothing extra at startup. The set of decisions is a versioned contract: a later release that adds one stops a host that calls this method until the new decision is answered or accepted, and such a release declares the addition as a behavioural breaking change naming the new risk and why it was added. IProductionProfileCheck is the seam behind it — more than one check may carry the same risk, and the strictest answer wins, which is how Tracon.AspNetCore adds the UseTenancy(options => options.Enabled = false) case the core cannot see.
  • Tracon:SessionOwnership:RefuseUnownedSessions (default false). Session ownership is not retroactive, so rows written before it was turned on belong to nobody; until now those stayed readable by id to anyone in the tenant, and only disappeared from owner-filtered listings. With this on they are refused instead — 404 on the session and /v1/conversations routes, 403 (errorType session_owner_required) on a run that names one, 404 on a voice socket. A caller who satisfies ManagementPolicy still reads them, so support keeps the access it already had in the management listing; that exemption does not extend to starting a run. A session that does not exist yet is unaffected: the first turn still opens it and claims it.
  • TraconEndpointOptions.MapOpenAIConversations (default true). Set it to false in MapTracon to leave the four /v1/conversations routes unmapped; they then answer 404 and disappear from the OpenAPI document. /v1/responses and /v1/chat/completions are unaffected.
  • Licence. Tracon now ships under the PolyForm Small Business License 1.0.0 instead of MIT. Use is free of charge for an individual, an open source project, and any company with fewer than 100 total individuals working as employees and independent contractors and less than 1,000,000 USD (2019, inflation adjusted) revenue in the prior tax year; above that threshold a commercial licence applies (hfarukatasoy@gmail.com). Three packages stay MIT so that writing an extension, proving it against the behaviour contracts, and owning the code dotnet new generates never need one: Tracon.Abstractions, Tracon.Testing.Contracts.Xunit and Tracon.Templates. The npm client @tracon/client follows its NuGet twin and is PolyForm. No package contains a licence key, an activation call or a feature gate, and the licence of a published version never changes. Because PolyForm is not OSI approved, packages now declare <license type="file"> and carry the text inside the .nupkg rather than naming an SPDX expression. Nothing was published under MIT, so no existing consumer is affected. See /reference/licensing/.

  • The /v1/conversations read and delete routes now go through your registered IRunAuthorizationHandler. GET /v1/conversations/{id}, GET /v1/conversations/{id}/items and DELETE /v1/conversations/{id} ask it with SessionAccess.Read, Read and Delete; they previously never asked it at all, while /api/sessions/{id} did. If you have a handler registered that refuses some sessions, it now refuses them on this surface too. The direction is fail-closed and it closes a real gap — an installation whose handler denied GET /api/sessions/{id} had GET /v1/conversations/{id}/items hand back the same chat history — but it is a behaviour change for existing setups. POST /v1/conversations is not gated: it reserves an identifier and writes nothing.

  • Model provider adapters for OpenAI (Chat Completions and Responses), Anthropic (Claude), Google (Gemini), and Azure OpenAI, plus an IModelProvider extension point for any other provider — with per-tenant bring-your-own-key (BYOK) support and per-tenant egress policy.
  • Persistence backends for PostgreSQL, SQL Server, and SQLite, each with embedded migrations; the runtime (Tracon.Core) needs no database.
  • An embedded React console — 30 screens across 36 routes (Dashboard, Agents, Skills, Playground, Sessions, Runs, Workflows, Jobs, Evals, Experiments, Approvals, Tools, Models, MCP, Triggers, Audit, Diagnostics, Settings) — with zero JavaScript dependency in the consuming project.
  • A management HTTP API alongside OpenAI-compatible endpoints (/v1/responses, /v1/chat/completions, /v1/conversations). Multi-tenancy is opt-in: TraconTenancyOptions.Enabled is false by default and every request resolves to the single default tenant until you turn it on.
  • Run recording with spans, metrics, and cost; a tamper-evident audit trail with a verifiable hash chain; data subject export and erasure.
  • Cost provenance: a priced run records the unit prices actually applied and the provider that answered it, so a historical cost stays explainable after the price list changes.
  • Named job lanes: a job carries a lane, a worker subscribes to the lanes it serves, and each lane can hold its own concurrency budget — unrelated kinds of background work no longer block one another. Queue depth, throughput, and duration are reported as OpenTelemetry metrics.
  • Opt-in structured-response validation: when an agent asks for JSON or a JSON schema, the response can be checked before the run closes, with a bounded repair turn that stays inside the same run’s budget, cost accounting, and cancellation. A run that carries a durable session gets no repair budget: the underlying agent framework persists a turn as soon as that one model call completes, so a repaired run would return an answer its own session never recorded. Such a rejection fails the run exactly as it would with repair switched off, and its rejection event carries repairSuppressedBySession to tell that apart from a run that simply had no repair configured.
  • Tool schemas generated from your method signatures express JSON Schema constraints taken from standard System.ComponentModel.DataAnnotations attributes, and support nested object and object-array parameters.
  • Extension points for custom tools, model providers, run stores, run judges, agent sources, and scheduled job handlers, each with a published behavior-contract test suite (Tracon.Testing.Contracts.Xunit) so a third-party implementation can be verified against the same tests Tracon’s own implementations run.
  • Workflow execution with five orchestration patterns, checkpoints, resume, and human-in-the-loop approval.
  • MCP tool discovery from remote servers, and agent exposure over MCP and A2A.
  • A typed management client generated from the OpenAPI document, published both as a NuGet package (Tracon.Client) and an npm package (@tracon/client).
  • A dotnet new tracon-api project template and an tracon global CLI tool (migrate, migrate status, health, eval).
  • Native AOT and trimming compatibility for Tracon.Abstractions, Tracon.Core, Tracon.PostgreSql, Tracon.OpenAI, Tracon.Anthropic, Tracon.Google, Tracon.Azure, and Tracon.Voice.