Repository navigation
Update dependency @flue/runtime to v2 - #423
Open
mend-for-github-com[bot] wants to merge 1 commit into
Open
mend-for-github-com[bot] wants to merge 1 commit into
mend-for-github-com[bot] wants to merge 1 commit into
Conversation
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
1.0.0-beta.9→2.0.0By merging this PR, the below vulnerabilities will be automatically resolved:
Release Notes
withastro/flue (@flue/runtime)
v2.0.0Compare Source
Breaking Changes
Flue is now a Vite plugin —
flue devandflue buildare removed. Adopt Flue by addingflue()from the new@flue/vitepackage tovite.config.ts;vite devandvite buildown the deployable application. The Node target emitsdist/server.mjs(run it withnode dist/server.mjs;vite previewfor Node is deferred). The Cloudflare target is the official@cloudflare/vite-pluginas an explicit sibling —plugins: [flue(), cloudflare({ config: flueWorkerConfig() })]— with the target auto-detected fromcloudflare()'s presence;wrangler.jsoncstays user-owned, and Flue's contributions (the generated Worker entry, served as thevirtual:flue/workermodule, and the per-agent Durable Object bindings) are applied in memory by theflueWorkerConfig()customizer — nothing is generated into the project tree.flue.config.tssurvives as the host-independent project config:defineConfigmoves to@flue/runtime/config(the@flue/cli/configexport is removed), the accepted fields aretarget,app,db,cloudflare,agents(a glob narrowing the'use agent'scan),providers, andtracing, and theroot/outputfields and namedviteexport are dropped. See the migration guide for the full before/after.File-based routing is removed —
app.tsis the route map. Thesrc/agents/*,src/workflows/*, andsrc/channels/*directory conventions no longer create routes or registrations. An agent module joins the application through the'use agent'directive (the module's first statement); the build scans source files for the directive and registers every capitalized exported function as an agent, deriving each agent's durable storage identity from the exported function's name — or anagentNamestatic pinning it — never from the file (on Cloudflare, one generatedFlue<PascalName>AgentDurable Object class per exported agent function; renaming an unpinned function is the storage-identity change, expressed with wrangler-nativerenamed_classes, and colliding identities are a build error — renaming the file changes nothing). Every HTTP route is mounted explicitly inapp.ts:app.route('/agents/triage', createAgentRouter(Triage))for agents (createAgentRouterfrom@flue/runtime/routing),app.route('/channels/slack', slack.route())for channels (hand-rolled channels mount viacreateChannelRouter). The router factory is pure and side-effect-free — registration comes from the scan, so dispatch-only agents need no mount, and the same agent can be mounted at two URLs. The agent module's named exportsroute(middleware),attachments, anddescriptionare removed along with the convention that read them: protect a mount with ordinary middleware layered inapp.tsbefore it, and the attachment byte route is part of every agent router rather than opt-in.createDefaultFlueApp()and the runtimeflue()router are removed; a missingapp.tsis a build error.Workflows are removed.
defineWorkflow,invoke, run stores and run event streams, the/runs/:runIdandPOST /workflows/:nameroutes (including?wait=result),listRuns/getRun, the SDKclient.workflows/client.runsnamespaces, the React workflow hooks, and the dev-console run UI are all deleted, with no compatibility stubs. Conversations are the only durable unit: a workflow becomes a program that drives an agent —flue runin a shell or CI step, thestart()/init()JS API from Node, the Agent SDK over HTTP, or an external durable-workflow engine (Cloudflare Workflows, Inngest, Temporal) that checkpoints the dispatch receipt in one step and reads the settled reply in another. A deterministic code-first entrypoint (the old no-modelrun()body) has no direct replacement. The migration guide covers the conversion.Agent authoring is rewritten: an agent is a plain exported function configured with hooks, and
defineAgentis removed. A beta agent was adefineAgent(initializer)default export whose initializer returned a config object (model,instructions,tools,actions,skills,subagents,sandbox, …); now the capitalized exported function in a'use agent'module is the agent. It runs fresh before every model call (a render), its return string is the instruction document, and everything else is declared by calling hooks during the render:useModel(model, { thinkingLevel?, compaction? })(required, exactly once — the oldmodel/thinkingLevel/compactionconfig fields),useInstruction(text),useTool(tool),useSkill(skill),useSubagent(subagent),useMcpConnection(definition),useSandbox(factory, { cwd? }),usePersistentState(name, default?)(durable per-instance state with React-style functional updates),useInitialData()(instance-creation data),useDelivery()(the delivered message in front of the model),useDataWriter(name, { schema })(named client-facing data parts),useDispatchMessage()(an instance-bound dispatcher), and the lifecycle and response-boundary hooksuseAgentStart(run),useAgentFinish(run)(whosectx.appendcan send the model back to work in the same response),useResponseStart(fn), anduseResponseFinish(fn)(response metadata). Because the render re-runs each turn, declarations may be conditional: a tool, skill, subagent, MCP connection, or sandbox that appears or disappears between renders takes effect at the next turn boundary and is narrated to the model as a structured delta signal. Custom hooks are ordinaryuse*functions — composition needs no registration. The contract the platform reads without running the function lives in statics on the function itself:agentName(the durable-identity override),initialData(a Valibot schema for creation data), anddurability({ maxAttempts, timeoutMs }, previously a config field). Deleted with the old model:defineAgent,createAgent,defineAgentProfileandAgentProfile, and the whole Actions surface (defineAction, theactions: [...]list, and their error types) — a model-invoked job is auseTooltool now, withharness: truefor one that needs to prompt the harness. Subagents are declared values too:useSubagent(defineSubagent({ name, description, agent, model?, thinkingLevel? }))renders a delegate for the always-presenttasktool,GeneralSubagentis the exported general-purpose delegate, and ataskcall can only name a declared subagent. See the migration guide.The tool
run()context and the harness are reshaped.ToolContextis now{ toolCallId, log, signal? }plusdata— the schema-parsed arguments, renamed frominput— when the tool declares aninputschema,harnesswhen it declaresharness: true, andstepwhen it declaresdurable: true; every tool getslogfor structured progress events. The harness handed toharness: truetools and to theuseAgentStart/useAgentFinishcontexts flattens to{ name, prompt(), compact(), sandbox }:session(),sessions,shell(), andfsare removed, andharness.sandboxis the liveSessionEnv(exec,readFile,writeFile,stat,readdir,mkdir,rm, …), present only when the agent declared a sandbox. The six built-in tools (bash,read,write,edit,grep,glob) are composable per-tool factories (createBashTool(env),createReadTool(env), …) that aSandboxFactory's optionaltools()can reassemble, extend, or replace wholesale.Sandboxes are opt-in: an agent that declares no
useSandbox()has no execution environment. The beta gave every agent an implicit in-memory virtual sandbox with full network access; now the six built-in shell/filesystem tools are added only when the render declares a sandbox,harness.sandboxand workspace-skill reads throw a clear declare-a-sandbox error otherwise, and workspace discovery contributes nothing to the prompt. Framework tools —task,activate_skill, packaged-skill reads — are environment-independent, so skills and subagents work sandbox-less.useSandbox(factory, { cwd? })is the only way to attach an environment:local()(@flue/runtime/node) runs commands on the host,cloudflareSandbox(stub)(@flue/runtime/cloudflare) runs them in a Cloudflare Sandbox container, and the beta default's equivalent isuseSandbox(bash(() => new Bash({ fs: new InMemoryFs() })))withjust-bashadded to your own dependencies — the same you-import-the-library pattern as model providers. The declaration is submission-scoped and may be conditional: presence is re-read at every turn boundary, and a flip attaches or detaches the environment before the next model call, announced to the model as one fullenvironmentsignal (new cwd, complete tool roster, a warning that the previous environment's files may be inaccessible). Not constructing the default environment also slims deploys that never opted in: the cloudflare example's main chunk drops from 5,481 kB to 3,482 kB.Skill and markdown imports drop their import attributes — the specifier decides. Importing a path that resolves to a
SKILL.mdpackages that skill directory at build time and yields aSkillReferenceforuseSkill(...)(static imports only; frontmatter validated against the Agent Skills spec); importing any other.mdfile yields the markdown text as a plain string, whichdefineSkill({ name, description, instructions })turns into a skill. Thewith { type: 'skill' }andwith { type: 'markdown' }attribute forms are removed.defineSkillnow validates and freezes instead of packaging —instructionsis required, packaging happens lazily, and the runtime writes the frontmatter — anduseSkill(...)accepts aSkillReferenceor aSkillDefinitiondirectly. Manual skill invocation is gone: skills mount into the agent's disclosure catalog (or join it via workspace discovery under a sandbox's.agents/skills/), and the model activates them through the framework'sactivate_skilltool.MCP servers are declared as connection definitions;
connectMcpServeris removed. One flatMcpConnectionDefinition({ name, url, transport?, auth?, headers?, tools?, optional?, timeoutMs?, … }) drives the whole surface:useMcpConnection(definition)declares a server from the agent render — its tools mount asmcp__<server>__<tool>, read once per submission and cached per agent instance —defineMcpConnection(definition)is the validating, freezing typing helper for the exportable unit (spread it for per-mount overrides), andcreateMcpConnection(definition)is the imperative factory replacing the beta'sconnectMcpServer(name, options)for module-scope use on the Node target (McpServerConnectionis renamedMcpConnection). New in the definition:auth— a static bearer token or a function resolved freshly on every request, the seam for per-user and rotating credentials (a 401 retries once after the credential resolver is consulted);tools— an allowlist by the server's own tool names, where a name the server doesn't expose is a hard error; andoptional: true— a server that fails to resolve mounts zero tools for that submission instead of failing it, announced to the model once as aresourcessignal naming each unavailable server and surfaced to observers as a warning event (failures are never cached, so the next submission retries). Underneath, the adapter runs on the v2 MCP SDK (@modelcontextprotocol/client, a direct dependency of@flue/runtime): structured tool output is validated by the client itself with a workerd-compatible validator, and existing v1 servers interoperate unchanged.Model providers are Pi-native:
registerProviderandregisterApiProviderare removed, and theproviderslist is exhaustive. A custom or customized provider is a pi-aiProviderobject registered withsetProvider(provider)— built withcreateProvider(...)and the built-in factories from@earendil-works/pi-ai, which you depend on directly — replacing the beta's option-bagregisterProvider(id, { ... }),registerApiProvider, and the deprecatedpi-ai/compatlayer under them (model capabilities such asreasoning,input,contextWindow, andcostare plain fields on theModelobjects a provider declares). Model specifiers keep the'provider-id/model-id'format. The newproviders?: string[]config (influe.config.tsor inline on theflue()plugin) selects which built-in providers exist: omitted, every pi built-in registers as before; set, only the listed providers — catalogs and protocol implementations included — are bundled and resolvable, and the narrowing is real bundle weight:examples/cloudflarewithproviders: ['anthropic']deploys 5.8 MB instead of 9.6 MB (#359).'cloudflare'in the list selects the Workers AI binding provider —cloudflareBindingProvider({ binding, gateway? })from@flue/runtime/cloudflare/workers-ai, auto-registered on Cloudflare builds when the list is omitted, with its wire-protocol implementations now loaded lazily so Durable Object cold starts stop paying for them — and naming it on the Node target is a config error pointing at the HTTP alternatives (cloudflare-workers-ai/cloudflare-ai-gateway). Programmatic runs get the same selection asstart({ providers }), takingProviderobjects.Channel helper APIs are renamed
instanceId/parseInstanceId. The value these helpers produce was always the agent instance id, so every channel package renamesconversationKey→instanceIdandparseConversationKey→parseInstanceId(zendesk'sticketKey/parseTicketKeyincluded), with theInvalid*ConversationKeyErrorclasses following asInvalid*InstanceIdError— a clean break, no aliases.parseInstanceIdis also demoted from showcase to escape hatch: agents receive structured facts as creation data (useInitialData()) rather than parsing them back out of the id.The SDK and React hooks address one conversation by URL. The framework no longer knows where agents live —
app.tsis the map — so the client collapses to the resource it talks to:createFlueClient({ url: 'https://host/agents/triage/123456' })(the agent's mount URL plus a caller-chosen conversation id) withsend(),wait(),history(),observe(),abort(), andattachmentUrl(). ThebaseUrl+ agent-name/id addressing, theclient.agentsnamespace, andlistAgents()are removed; a new conversation is the caller appending a fresh id to the mount URL.@flue/react'suseFlueAgent({ url })replacesuseFlueAgent({ name, id }).Assistant output projects as one conversation message per response. An assistant response spanning multiple model steps (text, tool calls, tool results, more text) previously projected one message per step; history snapshots and live updates now fold every assistant step of a submission into one message — parts accumulate in record order, usage sums across steps, identity fields stay the first step's — matching how the AI SDK ecosystem assembles one
UIMessageper response. Clients that keyed logic on per-step assistant messages see fewer, larger messages; the chunk wire vocabulary is unchanged.The
@flue/dev-consoleTUI package is removed. The terminal chat console is not a direction Flue is investing in; local manual testing lives in the in-browser demo chat app (demo/in the repository), which is the basis for a futureflue devexperience.flue runis rewritten as transport-free local execution, and the CLI slims torun/init/add/update/docs.flue run <path-to-agent-module> --message "..."compiles and runs one agent module under Node with no HTTP listener or Cloudflare emulation: product events stream to stderr, the final reply (or a--jsonenvelope) prints to stdout, and the conversation id is printed so--idcan continue the same conversation across invocations. Persistence honors the project'sdb.tswhen configured, else a project-local cache database. Removed flags:--server,--header,--target,--root,--output,--config, and--input(superseded by--message);flue run <name>(a bare agent name) is replaced by the module path form.flue devandflue builderror with pointers to the Vite commands.dispatch()takes a structuredmessage, not an opaqueinput.AgentDispatchRequest.input: unknownis replaced bymessage: DeliveredMessage, the same unified shape a direct HTTP prompt uses internally:{ kind: 'user', body: string, attachments?: DeliveredAttachment[] }for a real chat turn, or{ kind: 'signal', type: string, body: string, attributes?: Record<string, string>, tagName?: string }for a structured event/webhook payload.bodyis always a string — JSON-stringify structured payloads yourself.dispatch()can now deliver akind: 'user'message with image attachments, the same way a direct HTTP prompt does (attachments onkind: 'signal'are not supported).messageis validated the same way as a direct prompt's body (a malformedmessagethrowsInvalidRequestError) instead of being forwarded unchecked. On the adapter surface, the submission input types collapse into oneAgentSubmissionInputinterface ({ kind: 'dispatch' | 'direct', submissionId, agent, id, message, acceptedAt }) —DispatchAgentSubmissionInputandDirectAgentSubmissionInputare removed, a dispatched submission's persisted input carries onlysubmissionId(no separate dispatch-scoped id), and the persisted-attachment helpers exported from@flue/runtime/adapterare renamed:prepareDirectSubmission→prepareSubmissionAttachments,hydratePersistedDirectSubmission→hydratePersistedSubmissionAttachments,matchesPersistedDirectSubmission→matchesPersistedSubmissionAttachments. Every first-party channel example and blueprint is updated to the new shape, following one convention:bodycarries the message itself and structured metadata (sender identity, ids, titles) goes inattributesas flat strings.dispatch()targets an agent definition, not a name string. The single signature isdispatch(agent, { id, message }), whereagentis the default export of a'use agent'module. Thedispatch({ agent: '<name>', id, message })named-string form is removed — a non-definition first argument throwsInvalidRequestError— and theNamedAgentDispatchRequesttype is no longer exported from@flue/runtime.The direct agent HTTP wire body is a
DeliveredMessage.POST /agents/:name/:idnow accepts the same validatedDeliveredMessageshapedispatch()admits —{ "kind": "user", "body": "...", "attachments"?: [...] }for a chat turn (or akind: 'signal'event) — replacing the{ message, images? }wire body and the internal mapping layer behind it.@flue/sdk'sAgentPromptOptions.messageis now aDeliveredMessage(wasstring; theimagesoption folds intoattachments), andAgentPromptImageis renamedDeliveredAttachmentin@flue/sdkand@flue/react. TheDirectAgentPayloadtype is removed from@flue/runtime.flue rundelivers its--messagetext as akind: 'user'message.Signal
tagNamemust be a valid XML tag name. The optionaltagNameon akind: 'signal'message is rendered as the signal's XML envelope in model context, so it is now validated (letters, digits,_,-,.; must not start with a digit,-, or., and must be non-empty) and a malformed value throwsInvalidRequestErrorat admission instead of injecting markup past the body/attribute escaping.Direct agent prompts are fire-and-forget only. The
?wait=resultsynchronous mode on agent HTTP POSTs is removed; agent prompts always return a 202 admission. The in-process observer registry (createAgentSubmissionObserverRegistry,AgentSubmissionObserver,AgentSubmissionObserverRegistry), theDirectAttachedOptions/invokeDirectAttached/runDirectSyncModeadmission path, and theresultfield onSubmissionSettledRecord,AgentConversationSettlement,AgentSubmissionSettledEvent, and thesubmission_settledFlueEvent variant are all removed. Callers that need the actual assistant reply should read it from the conversation transcript via the conversation client'shistory()or the live conversation stream.The SDK's
prompt()is removed. Use the conversation client'ssend()(fire-and-forget) pluswait()(completion-await, nowPromise<void>) plushistory()(to read the reply).AgentPromptResultandAgentPromptResponsetypes are removed from@flue/sdk.The SDK's
wait()no longer resolves with a result. It resolvesvoidon completion and throwsFlueExecutionErroron failure or abort. A remote abort is now distinguishable from a real failure:FlueExecutionError.failureis'aborted'for an aborted settlement (previously both classified as'failed').AttachedAgentEventCallbacktype removed from@flue/runtime. TheonEventcallback parameter on admission no longer exists.reconcileInterruptedSubmissionreturn type simplified. ReturnsAgentSubmission | undefined(the replacement submission, orundefined) instead of the 5-variantReconciliationResultdiscriminated union. Custom coordinator implementations that inspected.dispositionshould branch on truthiness instead.The
init()handle'sdispatch()is enqueue-only; awaiting the reply is the newread().handle.dispatch(request)now resolves at admission with the durableDispatchReceipt— the same contract as the top-leveldispatch(), so the verb means one thing everywhere — instead of awaiting the settled reply, and it no longer takes an options argument. The settled reply moves tohandle.read(receipt | submissionId, { onEvent?, signal? }), which resolves with theAgentReply(rejecting withAgentRunErroron a failed or aborted settlement) and re-attaches from any process at any later time: settlement and reply are durable conversation records, so a receipt persisted across a crash — for example as a workflow step's durable result — is all a retry needs, and a submission that settled long ago resolves immediately.read()'ssignalstops only the read (a local cancel; previously the dispatch signal requested a durable abort of the run) — the durable abort intent is the new explicithandle.abort(), which covers the running head and every queued submission. A read addressed to an instance that does not exist rejects withAgentInstanceNotFoundErroron every target rather than waiting for work that can never settle. TheAgentDispatchOptionstype is renamedAgentReadOptionsand belongs toread().One id vocabulary end to end:
dispatchIdis renamedsubmissionId.DispatchReceipt.dispatchId(the fielddispatch(),handle.dispatch(), anduseDispatchMessage()resolve with) is nowDispatchReceipt.submissionId;handle.read(receipt)andhandle.read(submissionId)are unaffected, and a bare-string read target is still the receipt's id, just spelledsubmissionId. New dispatch submission ids aresub_-prefixed (previouslydispatch_); ids remain opaque and olddispatch_-prefixed ids stay valid.FlueEventno longer has adispatchIdcorrelation field.submissionIdalone identifies a submission's activity — dispatched and direct alike — on every event and onFlueExecutionContext; code that branched ondispatchIdshould readsubmissionIdinstead. See the Events Reference.The
flue.dispatch.idtelemetry attribute is removed.@flue/opentelemetryand the native Cloudflare tracing adapter (createCloudflareTracing()) no longer emit it;flue.submission.id, already emitted alongside it, is the replacement.Trace content is captured by default, and
@flue/opentelemetry's content surface collapses tocontent?: false | { transform }. Both trace adapters —createOpenTelemetryInstrumentation()and the native CloudflarecreateCloudflareTracing()— now emit conversation content (gen_ai.input/output.messages, system instructions, tool definitions/arguments/results) unless you passcontent: false; installing an instrumentation withinstrument(...)is the consent (on Cloudflare, enabling Workers Traces), and atransformis the policy hook. On@flue/opentelemetrythis removesGenAIContentPolicyas a public type along withenabled,inline,externalContent(a side-effecting transform is the replacement —GenAIContentScopenow carriestraceId/spanIdfor correlation),limits(slice in the transform, or use the exportedtruncateContent(content, { maxBytes })), and thediagnosticcallback. Exception message and stack now ship by default through the same gate (strip them with a transform onexception_message/exception_stacktrace, or disable content); the Cloudflare adapter continues to exclude them unconditionally. Theflue.telemetry.content.*.truncated/.omittedmarker attributes are gone: truncation is structural and in-band — payloads stay valid JSON under a 56 KiB per-span content budget (every content attribute a span carries shares one pool, with a reserve so response content always has room beside large prompts), with oldest messages dropped behind arole: "flue"sentinel message and oversized strings cut with a[flue:truncated, …]suffix (FLUE_TELEMETRY_EXTENSION_REVISIONis now4).@flue/opentelemetryrequires a@flue/runtimeversion shipping the./telemetrysubpath.GEN_AI_SCHEMA_URLis removed, and@flue/opentelemetry's tracer/meter no longer declare aschemaUrl. The URL was fabricated — the OpenTelemetry GenAI semantic-conventions repository has not published a schema URL — so exporting it and stamping it on the wire handed schema-aware tooling a dead link with defined semantics. The pinned upstream revision thegen_ai.*keys were read from remains exported asGEN_AI_SEMCONV_REVISION.Persisted stores are stamped
format_version1, and stores written by 1.0.0-beta.x are rejected. Every persisted Flue store durably records the format version it was created with — aformat_versionkey in the SQL backends'flue_metatable, the equivalent on the non-SQL adapters — and refuses to open anything else, throwingPersistedFormatVersionError(type: 'persisted_format_version_unsupported'). The persisted shapes changed incompatibly across the 2.0 line (one unifiedmessageinput for both transports, thesubmission_idcolumn naming, chunked attachment storage), so a beta-era store must be cleared; there is no migration. For adapter authors, the stamp contract is exported from@flue/runtime/adapterasFLUE_FORMAT_VERSIONandassertSupportedFlueFormatVersion()(replacingFLUE_SCHEMA_VERSIONandPersistedSchemaVersionError), anddefineStoreContractTestsexercises the stamping rules on every backend.A tool's
run()returns a result envelope —{ output?, terminate? }— and bare non-string values are rejected.outputis the tool's result value, validated against the declaredoutputschema and serialized for the model exactly as before;terminate: trueends the agent's turn once the current tool batch settles — the same loop-ending contract the built-infinish/give_uptools use, so a custom tool can conclude a response without another model round-trip (the turn ends only when every result in the batch terminates, and a throwing tool never terminates). Returning a barestringremains shorthand for{ output: <string> }, and returning nothing is still allowed for tools without anoutputschema; any other bare value (number, boolean, array, null) or an object with keys beyondoutput/terminatenow throws an error telling you to wrap it —return { output: <value> }— which is what makes plain-object returns unambiguous. Termination is durable: the flag is recorded on the tool's canonical outcome (additive — no storage schema bump), so a crash between a terminal tool batch's commit and settlement now recovers to the live outcome — the submission settles without an extra model turn — closing the same recovery gap for turns ended byfinish/give_up.SessionEnv.execnow rejects promptly on abort — an un-cancellable sandbox command becomes a documented orphan. Whenoptions.signalaborts mid-command, the returned promise rejects withAbortErrorimmediately instead of waiting for the remote command to settle. Previously, aborting agent work during a command on a signal-blind provider SDK (E2B, Daytona, Modal, and most others expose no mid-flight cancellation) blocked the entire abort path — the error tool result, the batch commit, thesubmission_abortedadvisory, and the next queued prompt — until the command finished on its own. The guarantee is implemented once, in the runtime's wrappers (createSandboxSessionEnvand thebash()factory adapter), soSandboxApiitself is structurally unchanged and every adapter built on the wrappers gets the behavior for free; the first-party Cloudflare adapter's own local abort race is removed in favor of the shared one (its container death detector now handles liveness only). The still-running remote command is an explicit, named state — an orphaned command: its eventual settlement is consumed, never recorded (no conversation records, no tool events; a lateSandboxDiedErrorfrom an orphan is swallowed into the settlement), and is observable through the new optionalcreateSandboxSessionEnv(api, cwd, { onOrphanSettled })callback with the exportedOrphanedExecSettlementtype. The abort-time error message — which reaches the model as the error tool result — now carries the suffix "The sandbox command could not be confirmed cancelled and may still be running.", and a subsequent command may run in the same sandbox while an orphan is still executing (whether the provider serializes or interleaves is provider-defined; this was already the reality on Cloudflare). Deliberately breaking in two ways: callers that relied on an abort rejection implying the remote command had finished lose that (it was already false on Cloudflare), and hand-rolledSessionEnvimplementations built without the wrappers are now out of contract if they late-reject. The sandbox reference's "do not simulate mid-flight cancellation withPromise.race" guidance is withdrawn and inverted: the wrapper owns the abort race, and adapters must not add their own. No persisted-schema or record-shape changes — the abort path emits the same records, just without the wait.AgentSubmissionStoregains a required method:settleQueuedSubmission(submissionId, outcome, error). Terminal settlement for a queued submission that can never be claimed — atomic and queued-gated (the first terminal state wins against a racing claim), no attempt is created, and no canonical settlement record exists for it (the same already-tolerated class as the SQL stores' malformed-row termination: the row's outcome is authoritative when no stream record exists). CustomAgentSubmissionStoreimplementations must add it; every first-party backend (the built-in SQLite stores plus@flue/libsql,@flue/postgres,@flue/mysql,@flue/mongodb, and@flue/redis) ships it in lockstep, anddefineStoreContractTestsnow exercises it unconditionally.New Features
idempotencyKeyand retries converge instead of duplicating turns. Every real inbound channel is at-least-once — Slack redelivers events, webhook providers retry, a browser resends after a lost response — and until now each redelivery admitted a brand-new submission. Both transports now accept an optional caller-chosen key (≤256 chars):dispatch(agent, { id, message, idempotencyKey })(and theinit()handle's dispatch), a reservedidempotencyKeybody sibling besideinitialData/uidon the directPOST /agents/:name/:id, andsend({ message, idempotencyKey })in@flue/sdk. Two admissions to the same agent instance with the same key are the same submission — delivered and answered at most once, every admission returning the same receipt, with replays markeddeduplicated: trueand echoing the originalacceptedAt(direct replays re-attach from the stream origin offset). The key names the delivery, not the outcome: a submission that settledfailedstays failed — retry with a fresh key to ask again. Reusing a key with a different payload rejects with the new structured 409,SubmissionConflictError(type: 'submission_conflict', the existing submission's id inmeta.submissionId), identical on both targets — the Cloudflare internal dispatch conflict body is now structured JSON rehydrated Worker-side instead of bare text. No storage schema change and nothing new asked of persistence adapters: the key deterministically derives the submission id (sub_ik_+ SHA-256 over agent/instance/key, a frozen wire format), so the existing idempotent admission machinery does the rest, and custom stores get the feature for free. Channel packages stay stateless — pass the provider's redelivery-stable id through (idempotencyKey: payload.event_idfor Slack); the channel READMEs and guide show the mapping.@flue/runtime/telemetrysubpath: the backend-neutral GenAI content machinery shared by the native Cloudflare tracing adapter and@flue/opentelemetry— the role/parts message projection (inputMessages,outputMessages,systemInstructions,toolDefinitions, …), the content pipeline, thetruncateContent(content, { maxBytes })helper (same structural in-band truncation and[flue]sentinels as the built-in safety net, for tighter policy budgets inside a transform), theCONTENT_ATTRvocabulary, and the semconv revision constants. Dependency-free and Node-evaluable; the runtime stays free of@opentelemetry/api.createCloudflareTracing()now captures conversation content into Workers Traces by default — input/output messages, system instructions, and tool definitions/arguments/results on the samegen_ai.*attributes the OTel adapter emits, so Flue conversations read directly in the Traces dashboard.content: falserestores content-free spans;content: { transform }redacts or drops in code. Raw error messages and stacks never ship on this backend, caller-originbashis traced as the same flue-ownedflue.operation shellspan@flue/opentelemetryemits — nogen_ai.*claims and no content, since a user's shell operation is not a model tool call — and all content projection runs only on sampled invocations.invoke_agent/chat/execute_toolspans, noinstrument(...)line needed. InstallingcreateCloudflareTracing(...)yourself customizes as before;tracing: falseinflue.config.tsopts out.purpose(user,assistant,dispatch, oradvisory) anddisplay(visible,hidden, ordiagnostic), plus optionalturnIdgrouping and asignaldescriptor, so clients can distinguish public chat from internal, control, and advisory activity without parsing message text, timestamps, or ordering. The classification is applied identically across conversationhistory()snapshots and live updates, and@flue/sdk/@flue/reactshapes are updated in lockstep (#404).vite previewnow works on the Node target.vite buildemits two entries — the self-startingdist/server.mjsand the non-listeningdist/app.mjsapplication chunk it imports — and preview serves the builtapp.mjsnatively (no Vite transformation), so what preview serves is exactly whatnode dist/server.mjswould serve, including production persistence defaults and the real process environment. Running preview without an artifact fails with "Runvite buildfirst."vite devon the Node target now loads the project's.envfile set (.env,.env.local,.env.<mode>,.env.<mode>.local) into the application's environment, with shell-exported values taking precedence — matchingflue run, so model-provider keys work without a shell export. The built server still reads only the real environment, and Cloudflare.dev.vars/.envhandling stays with the Cloudflare plugin.ssrFixStacktrace).flue initis now interactive and scaffolds the full project skeleton for the new surface instead of onlyflue.config.ts: missing choices are prompted, with--targetand--deployanswering them for scripted runs, and when the HTTP server setup is included it also writes avite.config.tswith theflue()plugin (pluscloudflare()on the Cloudflare target), a startersrc/app.tsroute map, and — on the Cloudflare target — awrangler.jsoncwithnodejs_compat,observability.tracesenabled, and commented migration guidance.--forcestill applies only toflue.config.*; the other files are created only when absent and never overwritten.@flue/react'suseFlueAgent()now exposesrefresh(), so apps observing an agent conversation that may be created out-of-band (a server-side wakeup, queue worker, or webhook) can re-check on their own schedule instead of faking an empty history snapshot. Retry policy stays in userland (#403).FlueError, or a non-HTTPFlueErrorthat escaped to a route), it mints anerr_-prefixed ULID and stamps it in three places: the envelope's newerror.reffield, a newflue-error-refresponse header (which survives body-less HEAD responses and body-swallowing intermediaries), and the matching server-side log line's[flue] [err_…]prefix — so a caller reporting a production 500 can point operators at the exact log entry, and the ULID alone brackets the time window. Caller-mistake 4xx envelopes stay unlogged and ref-free ("ref iff logged"). When the failing request carried a W3Ctraceparent, it is logged beside the ref (never echoed on the response).@flue/sdk'sFlueApiErrorexposes the ref asref(envelope body first, header fallback) and includes it in the composed message —Flue API error 500 [internal_error] (ref err_…): …— so the ref lands in calling services' own logs with no extra code. The failed-submission settlement text now also directs callers to quote thesubmissionId, the async-path counterpart of the ref.observe()submission vocabulary (queued → running → settled):submission_queuedis emitted immediately after durable admission on both targets (at-least-once — idempotent replays re-emit);submission_runningis emitted from the attempt's own context on every attempt, carryingattemptCount/maxAttempts, so a recovery replacement re-announces itself with the incremented count; andsubmission_recoverysurfaces every contained coordinator recovery failure — admission-side materialization, settlement finalization, interrupted-attempt reconciliation, attempt start/processing, and pass-wide failures — withoperation/outcomediscriminators, the same stackless error shape assubmission_settled(the live observation adds the classifiederrorInfowith throw-site stack), and re-emission on every failed wake while the condition persists, closing the alerting hole where a submission stuck in a retry loop was visible only in stderr (each site's structuredconsole.errorremains). Deriving per-instance busy/idle by keyingqueued/runningagainstsettledonsubmissionIdis now a documented stable pattern that converges across process restarts and Durable Object eviction; a delivery that joins a busy conversation emits queued + settled but never running. All three are live-only events like the rest of the stream — no conversation records, no schema change — and existing observers that branch on specificevent.types are unaffected (exhaustiveswitchstatements need a default arm). See the Events Reference.start()andinit(): the programmatic agent client.start({ agents, db?, env?, providers? })(from@flue/runtime/node) assembles a transport-free Flue runtime in any Node process — agents passed as the functions themselves, an optionalPersistenceAdapter(default: in-memory), andstop()/await usingfor drain-and-dispose — so scripts, cron jobs, and tests drive agents without an HTTP server.init(agent, { id?, uid? })(from@flue/runtime, on both targets) returns the durable handle for one agent instance — an address, not a resource: it creates nothing and does no I/O, so it is safe at module scope — withdispatch()(enqueue-only, resolving at admission with the durableDispatchReceipt; a bare string is shorthand for a user message,{ message, initialData?, idempotencyKey? }the full payload),read()(await the settledAgentReply, re-attachable from any process at any later time), andabort(). On Cloudflare the handle works inside cron callbacks, queue consumers, and Cloudflare Workflow steps — checkpoint the receipt as one step's durable result and read the reply in the next — the recommended shape for durable multi-step orchestration now that workflows live outside the framework.durable: truetools get checkpointed steps. A tool declareddurable: truereceivesctx.step, andstep.do(name, fn)runsfnonce per name per tool call, durably recording the result before resolving. An interrupteddurable: truecall is re-executed on recovery with completed steps replaying their recorded values — the code between steps re-runs, memoized steps do not — instead of being settled with an unknown-outcome interrupted error like an ordinary tool. The guarantee is exactly-once-recorded, at-least-once-executed (a crash in the window betweenfncompleting and the record landing re-runs that one step), so steps around external effects should be individually idempotent; step values must be JSON-serializable and small, names deterministic and unique within a call. A thrown error is not an interruption — a durable tool that throws settles as an ordinary tool error the model sees, with no retry-budget burn — and the flags compose:durable: true, harness: truereceives bothstepandharness, withharness.prompt(...)wrapped in a step so recovery doesn't re-prompt.initialDatastatic, read the parsed value withuseInitialData(), and seed it from any sender —dispatch(agent, { id, message, initialData }), theinit()handle'sdispatch({ message, initialData }), a reservedinitialDatabody sibling on the direct HTTPPOST,send({ message, initialData })in@flue/sdk, andflue run --data '<json>'. Validation runs synchronously at admission, before anything durable is admitted — a mismatched creating send rejects instead of queuing an unrunnable submission — and data sent to an already-existing instance is ignored. Every instance also gets auidminted at its birth (inst_…, carried on everyDispatchReceiptand SDK send result), and sends can be made conditional on it ETag-style with theuidoption: a string continues only that incarnation (a missing instance or mismatched uid rejects at admission withAgentInstanceNotFoundError/ 404, nothing durable),nullcreates only (an existing instance rejects withAgentInstanceExistsError/ 409 carrying the existing uid inmeta.uid), and omitted stays unconditional. The CLI speaks the same contract (flue run --uid <uid>/--new), andgetAgentInstance(agent, id)looks up an instance's uid when no receipt is at hand.answeredBySubmissionId) — while a delivery that misses the live response runs as its own submission afterward, so nothing is lost either way.observe()now receives streaming tool-call argument deltas: the newtoolcall_deltaevent carries{ toolCallId, toolName, argumentTextDelta }while a model streams a tool call's JSON arguments, so a UI can preview an in-flight, schema-enforced tool call as it is written. Live-preview only — never persisted or replayed; the complete arguments still arrive with the canonical tool-call record (#450).read(admission | submissionId, options?)— the HTTP counterpart of the handle'sread(). It awaits the submission's settlement and resolves with its reply ({ text, data, metadata?, submissionId, uid? }), throwingFlueExecutionErroron a failed or aborted settlement; the bare-id form follows the conversation from the stream origin, so a process that persisted just the admission re-attaches at any later time and an already-settled submission resolves immediately.wait()remains the cheaper outcome-only primitive.@flue/sdkalso exportsreadSubmissionReply(conversation, submissionId), the pure projectionread()uses internally — extract a reply from ahistory()snapshot or anobserve()state with no extra fetch, including the coalesced reply when the send joined a busy response (replacing hand-pickedmessages.at(-1)reads that silently grab the wrong message on a busy conversation). The same projection backs the runtime'sinit().read(), so a reply read over HTTP and one read in-process agree.Fixes & Other Changes
@earendil-works/pi-aiand@earendil-works/pi-agent-coreto 0.83.0.ConversationStreamStoregains optionalputFoldCheckpoint/getFoldCheckpoint(the first optional store methods; implemented by every first-party adapter, additive tables/keys with no format-version event) — custom adapters without them stay fully functional and degrade to full replay with one warning per path. The idempotency contract relaxes exactly where the bookkeeping releases: replaying a streaming record after its message resolved now fails loudly instead of deduplicating silently; no legitimate path re-appends one (store-level producer sequencing dedupes batch retries).@flue/opentelemetrynow reserves the Stableexception.typeattribute for the exception class name (RateLimitError,TypeError, …), per the OpenTelemetry exception conventions — previously it duplicated Flue's error taxonomy, which stays onerror.typeonly. Applies to span exception events, span status, andgen_ai.client.operation.exceptionlog records alike; when a failure carries neither a throw-site class name nor a (content-gated) message, no exception record is emitted and the span'serror.typeis the whole failure record.gen_ai.agent.nameonexecute_toolspans (conditionally required by the GenAI semantic conventions when the owning agent is known), and the native Cloudflare adapter's chat spans gainserver.address/server.port,gen_ai.conversation.compacted, andopenai.api.type, closing the attribute gap with@flue/opentelemetry.tool_outcomeand the batch is committed — recorded outcomes are preserved and nothing is re-executed or resumed — so atasktool call can no longer rest as "still running" forever in history projections, and the settled turn stays visible to future model context instead of being silently dropped. For ataskcall the marker includes the retained child conversation id; the child transcript itself is untouched. An interrupted in-progress assistant stream is likewise completed as aborted at terminalization. The terminal advisory now carries the interrupted-call list as structuredattributes.interruptedTools(also onSubmissionRetryExhaustedError/SubmissionInterruptedErrormeta), so apps can settle their own run state without parsing text. Conversations already left dangling by earlier versions self-heal on their next prompt: a new submission settles abandoned trailing state before appending its input (#419).vite devand tovite previewon both targets, not just Nodevite dev, and are tightened to localhost: credentialed requests are allowed from localhost-family origins (Vite's own default origin set) instead of any reflectedOrigin, with the durable-stream coordination headers exposed — so separate-origin local clients (like the repository's demo chat app) can stream conversations from any local server shape, while an arbitrary website can no longer drive the local agent API through ambient credentials (#483). Non-localhost clients need an explicitserver.cors/preview.corsoverride — user-set values still win — and production servers keep CORS as an application concern.cloudflare:*module reaches a Node graph — undervite dev,vite build, orflue run— the diagnostic names the route to the problem (src/app.ts imports src/lib/platform.ts imports cloudflare:workers), not just the failing specifier, and adds the type-only escape hatch (import typeis erased at build time). Invite devthe chain also lands in the 503 envelope's dev field.@flue/vitehardening from a comparative review against SvelteKit's and TanStack Start's Vite plugins:resolve.dedupefor@flue/runtime/honoon every target, an explicit warning when user Vite config conflicts with valuesflue()enforces, a warning when multipleflue.config.*files coexist, stackless errors for expected user mistakes (the message is the diagnostic, without a framework stack burying it), hook-level transform filters (the import-attribute transform no longer parses every TS module in the graph), self-contained transform sourcemaps (sourcesContent), a file:line:column pointer on the missing-default-export diagnostic, and the Chrome DevTools well-known probe is answered with a 404 before it can reach a catch-all application route.outputSchema. JSON Schema validation now uses a codegen-free strategy compatible with the workerd runtime instead of runtime code generation (#400).submissionIdin conversationhistory()snapshots, so clients that group a user turn with its assistant answer by submission id keep that grouping after reload (#402).durationMson the resolveddynamic-toolpart in both history snapshots and live updates, instead of being available only on the ephemeral live event (#407).baseandwrapcallbacks are now typed against the concrete generated Durable Object constructor (GeneratedDurableObjectClass<TBase, TEnv>, a constructor producingTBase & DurableObject<TEnv>), replacing the unreleased generic type-preservingwrapsignature. Every