[Unreleased] - 2026-09-28
Published
@lousho/build-ai-agentandcreate-lousho-agentare on npm. The README and the installation, quick start, CLI and Agent Forge docs no longer say the package is unpublished; “Installing before the first release” is now “Installing from a local build” (docs/installation.md#installing-from-a-local-build) and covers trying an unreleased commit. The hint printed after a failedlousho initinstall no longer mentions a 404; it points to--sdk-path. The docs site moved to https://lousho.com.
Renamed
- The project is now lousho (it was
loushy), before the first npm release, so nothing was ever published under the old name. Everything that carried the name changed with it, and this changelog uses the new names throughout, including in older entries: the package@lousho/build-ai-agent(was@loushy/build-ai-agent), theloushoCLI (wasloushy),create-lousho-agent(wascreate-loushy-agent), the exportsuseLoushoAgent,loushoAgent,LoushoAgentSource,LoushoUIMessageChunkand the otherLousho*types, everyLOUSHO_*error code and environment variable (LOUSHO_MODEL,LOUSHO_API_TOKEN,LOUSHO_STORE, …), the.lousho/directory, thelousho.*span attributes and thedata-lousho-approvalstream part. Migration for a checkout that used the old name: replaceloushywithlousho(keeping the case) in imports, scripts, environment variables and config, and rename an existing.loushy/directory to.lousho/.
Added
- Remote sub-agent approvals go through the lead run (LOU-Y7.3): a
remoteAgent()task whose remote run pauses for a tool approval now pauses the lead run the way a local sub-agent does (it used to fail withLOUSHO_SESSION_AWAITING_APPROVAL): the lead’s pending approval (agent.approvals.list(), channel buttons, the dev chat, ACP permission requests) has the remote tool’s name and input andsubagentPath: [<remote agent>], and a remoteask_questionarrives as a question. Deciding it on the lead (resolve,streamResolve,answer) posts the decision to the remotePOST <url>/chat/:sessionId/approvals/:idand the continuation’s final answer is thetaskresult; a further pause pauses the lead again. The lead’s approval snapshot stores the remote session id, the remote approval id, thetaskIdand the agent name (never the token), so a fresh lead process on the same store can decide it. Failures while deciding (401, other non-2xx including the remote’s 404 for an approval no longer pending, network) are thetaskcall’s structured tool error withLOUSHO_REMOTE_UNAUTHORIZED/LOUSHO_REMOTE_REQUEST_FAILED. A remote agent with anoutputschema now returns its object as JSON with the footer (the V4.2 shape), read fromrun.done’sobject.RemoteSubagent.run()takespausableanddecision(typeRemoteRunOptions); the internal session client gainsresolveRemoteApproval()andSessionTurnSummary.object. Only a lead run without an approval store keeps the oldLOUSHO_SESSION_AWAITING_APPROVALerror. See docs/sub-agents.md#remote-approvals. - Structured output for sessions and sub-agents, typed for either zod major (LOU-V4.2):
createAgent({ output })andExecuteOptions.outputaccept a zod 3 schema, a zod 4 schema (zod/v4on zod 3.25, or zod 4) or a Standard Schema that can produce JSON Schema, andresult.objectis inferred from each without a cast (InferSchemaOutput, asdefineTool).AgentSessionis generic (AgentSession<TObject = unknown>):agent.session().send()and.stream()results carry the typedobject. A sub-agent with its ownoutputreturns its validated object as JSON (then thetaskIdfooter) as thetaskandagent_awaitresult; itsoutput-invalidfinish is a structured tool error. Sub-agents do not inherit the lead’soutput;remoteAgent()returns text only. The exported spec schemas (agentSpecSchema, …) are typed asSpecSchema<T>/SpecObjectSchema<T>, so the publishedschema-*.d.tsno longer depends on zod 3 generics (skipLibCheck: falseprojects on zod 4). The Ollama missing-peer note now saysollama-ai-provider-v2needs zod 4 (npm install zod@^4.0.0). See docs/structured-output.md. - Publish readiness (LOU-D49):
npm run pack-smoke(scripts/pack-smoke.ts, a CI job) packs the SDK andcreate-lousho-agent, checks the tarball (no.env, tests or secret-looking strings; entry count and size caps), runsnpm publish --dry-runfor both (nothing is published), installs the tarballs plus peers from the registry into a fresh project and verifies ESM and CJS loads of everyexportsentry, a mock-model agent turn, theloushobin (--help,doctor) andtscwithmoduleResolutionbundler and node16. - One event system (LOU-D41):
AgentEventlisteners for callers who do not iterate a run.createAgent({ onEvent: (event: AgentEvent) => void })and the newExecuteOptions.onAgentEvent(also taken byAgentExecutor.stream()andresumeAfterApproval()) are called synchronously with everyAgentEventof the run, onsend()/execute()as onstream(), sub-agents’ events included: the same events in the same order as the stream yields (a non-streamed run generates each model step whole, so its text is onetext.deltaper step). A run with a listener also reports hook events, permission decisions, budgets and guardrails to it when it is not streamed. A non-streamedresumeAfterApproval()with a listener now reports the decided call (run.start,tool.start,tool.done) like a streamed one. See docs/streaming.md#listening-without-iterating.
Deprecated
ExecuteOptions.onEvent,ExecutionEventandExecutionEventType(LOU-D41).onEventkeeps working: the executor now emits onlyAgentEvents, and an adapter derives the old events from them in the old order (start,text-complete,tool-call,tool-result,error,abort,finish, sub-agents’ tagged withsubagent), with a one-timeconsole.warn. Differences:finishnow comes after the run’s checkpoint is written (it was just before), and a run that fails outside a step (an input guardrail that throws,onRunEndthrowing) now gets anerrorevent too. Migration: replaceonEventwithonAgentEvent(orcreateAgent({ onEvent }));start->run.start,text-complete->text.done(step usage onstep.done),tool-call->tool.start,tool-result->tool.done/tool.error,abort/finish->run.done(finishReason),error->error({ name, message }). The full table is in docs/streaming.md#migrating-from-onevent—executionevent.
Removed
ExecuteOptions.streaming(LOU-D41): it was never read. UseAgentExecutor.stream()/agent.stream()to stream a run. Passing it in an object literal is now a type error; remove the property.ToolDescriptor.injectStreamingControlleranddefineTool({ injectStreamingController })(LOU-D41.2, deprecated earlier in this release): no SDK path ever called it, so removing the property changes nothing at runtime. Passing it is now a type error; remove the property.AgentExecutionOptions.streaming(LOU-D41.2): it was never read (AgentExecutionOptionsis a legacy type no SDK API takes). Remove the property; stream a run withagent.stream()/AgentExecutor.stream().
Fixed
- Documentation corrections: install commands, CLI command lists, optional peers, durable execution and compaction descriptions, the Status section, reasoning and file-part notes in the providers guide, and the
KVStorenote in the deployment guide now match the code. Ticket ids are gone from user-facing prose. lousho --help,-handhelpprint the usage and exit 0 (they were “unknown command” with exit 1) (LOU-D49).- Install truth (LOU-U20): the README, installation, CLI, quick start and Agent Forge docs,
lousho init --helpand the message after a failedlousho initinstall now say the package is not on npm yet and point to one section, “Installing before the first release” (docs/installation.md);npm install github:LinuxDevil/agent-sdkis documented as not working. Stale “planned / not yet” statements about agent-directory channels,toolCallIdin sandboxed tools and the Agent Forge Settings tab are corrected.
Changed
-
No
anyleft in the SDK’s types (LOU-D16):npm run lintnow fails on any ESLint warning, andno-explicit-any,no-unused-varsandban-ts-commentare errors. Runtime behavior is unchanged; some exported types are stricter, which can be a compile error where code read ananyvalue without checking it. What changed, and how to migrate:- Values the SDK cannot know are
unknown(narrow them, or cast to the shape you expect):FlowExecutionResult.outputand.variables,FlowExecutionContext.variables/session/memory,GenerateResult.rawResponse,StreamChunk.toolResult.result, extra keys ofLLMProviderConfig,AgentConfig.expectedResult/events/metadata,AgentExecutionResult.result,ResultData.result,SessionData.data,JiraTicket.customFields, the extra keys offormatZodError()’s result,ToolConfiguration.options,ToolSetting.options,FlowToolSetting.options,ToolNode.toolOptions,UIComponentNode.componentProps,FlowChunkEvent.issues/input/toolResults[].args/componentProps, andToolDefinition.function.parameters. FlowExecutionEventis a union discriminated ontype, withdatatyped per event type (newFlowExecutionEventOf<T>andFlowExecutionEventDataMap): checkevent.typebefore readingevent.data(if (event.type === 'tool-call') event.data?.tool).AgentConfig.settings(andAgentBuilder.setSettings()) is the newAgentSettings({ model?: string; [key: string]: unknown }).StorageService.readPlainJSONAttachment<T>()(andIStorageService’s) defaultsTtounknown: pass the type you stored,readPlainJSONAttachment<MyRecord>(key).OpenRouterProvider.getModelInfo()returns the newOpenRouterModel({ id: string; [key: string]: unknown }),nullorundefined.FlowBuilder.addAgent()/setAgents()takeFlowAgentDefinition;injectVariables()andapplyInputTransformation()take aFlowDefinitionNode(injectVariables()returns the type it was given);validateFlowInput()takesRecord<string, unknown>;validateAgentTools()takesRecord<string, { tool?: unknown }>;createDynamicZodSchemaForInputs()returnsz.ZodObject<Record<string, z.ZodTypeAny>>.- Still accepted as before:
needsApprovalpredicates typed for a tool’s own arguments, any argument toformatAxiosError()andhasRequiredKeys(), and anything aswritePlainJSONAttachment()data. The deprecatedExecutionEventkeepstoolResult.result: any.
- Values the SDK cannot know are
-
Eval cassettes hook in at the model boundary (LOU-D46.2):
lousho eval --record / --replay / --driftno longer reassignsAgentExecutor.executeat runtime. The run loop routes every model call through a small provider-interception seam (setProviderInterceptor()/interceptProvider()insrc/providers/interception.ts; a no-op unless an interceptor is installed), and the eval runner installs one that answers with the per-caserecordReplay()wrapper. Cassette files, keying,--driftoutput and JUnit reports are unchanged, so existing cassettes keep replaying. Streamed runs and sub-agents inside an eval case are covered by tests. -
More errors carry SDK error codes (LOU-D2.2): the plain
Errors thrown bysrc/execution(historyLimit,toolConcurrency, a second iteration of anAgentRun), the tool registry, the built-in tools,serveMcp(),NodeWorkspaceand thelousho init/studio/ chat helpers are nowSDKErrors with an existing code (LOUSHO_CONFIG_INVALIDviaConfigurationError,LOUSHO_RUN_ALREADY_ITERATED,LOUSHO_APPROVAL_NOT_FOUND,LOUSHO_PROVIDER_UNKNOWN,LOUSHO_TOOL_EXECUTION_FAILED). Messages are unchanged;error.code,error.hintand a[code] hint (docs)line (not on the built-in tools’ run-time failures, whose message is the model-visible tool result) are added. Code that matchederror.constructor === Errororerror.name === 'Error'needserror instanceof SDKError. -
The remaining user-reachable plain
Errors carry SDK error codes (LOU-D2.3): agent directories, flows, memory, skills, storage (SQLite), triggers, deploy, evals, record/replay cassettes,defineTool(), sub-agent options, channels, sandbox/credential-broker options and thellmregistry now throw anSDKError(message text unchanged, plus the[code] hint (docs)line, except for tool results and messages tests assert, which stay as written). New codes:LOUSHO_TOOL_ARGS_INVALID(whatToolArgumentsValidationErrorcarries now),LOUSHO_AGENT_DIR_INVALID,LOUSHO_SKILL_INVALID,LOUSHO_FLOW_INVALID,LOUSHO_STORAGE_FAILED,LOUSHO_TRIGGER_INVALID,LOUSHO_DEPLOY_FAILED,LOUSHO_EVALS_INVALID,LOUSHO_TEST_FAILED,LOUSHO_CASSETTE_INVALIDandLOUSHO_CHANNEL_REQUEST_FAILED, all indocs/errors.md. Code that matched these errors byerror.constructor === Errororerror.name === 'Error'must checkinstanceof SDKError/error.codeinstead.ToolExecutionErrortakes an optional fourthcodeargument. A new test (src/utils/plainErrors.test.ts) fails when new SDK code throws a plainError; the 5 that remain are listed there with the reason. -
The last two allowlisted plain
Errors areSDKErrors (LOU-D16): OpenRouter’s model-catalog fetch (LOUSHO_PROVIDER_REQUEST_FAILED;getModels()/getModelInfo()still catch it and fall back) and the UI runner’s failedPOSTto a remote agent (LOUSHO_REMOTE_REQUEST_FAILED, as used byuseLoushoAgent()and the Vue/Svelte bindings). Both keep their message text exactly (no help line appended), so the UI’s error event shows the same message, now withname: 'SDKError'instead of'Error'.SDKErroritself moved tosrc/utils/sdkError.ts(re-exported from the same places as before) so the browser entries can throw it without importingai. The allowlist insrc/utils/plainErrors.test.tsis down to the 3 intended sites.
Added
- Reasoning (LOU-V13): new
reasoningoption oncreateAgent(),send()/stream()(per call, overriding the agent’s),ExecuteOptionsandGenerateOptions:'none' | 'minimal' | 'low' | 'medium' | 'high'or{ effort?, budgetTokens?, summary?: 'auto' | 'none', force? }(typesReasoningOption,ReasoningEffort,ReasoningSettings). The built-in providers send it throughproviderOptionsonai4.3 and 6/7: OpenAIreasoningEffort(+reasoningSummary), Anthropicthinking: { type: 'enabled', budgetTokens }(effort table 1024 / 2048 / 8192 / 24576 tokens), Ollamathink(ollama-ai-provider-v2only; ignored with one warning onai4), and OpenRouter’s unifiedreasoningbody field (effortormax_tokens). It is only sent to model families known to accept it (force: trueoverrides);'none'sends nothing. New eventsreasoning.start,reasoning.delta(text) andreasoning.done(text,tokens?) in theAgentEventunion, emitted inside the step before its first text or tool call, fromaiv4reasoningparts and v6/v7reasoning-start/delta/end;ExecutionResult.reasoningholds the run’s reasoning text andGenerateResult.reasoninga call’s blocks (new typeReasoningBlock); new stream chunk typesreasoning-delta/reasoning-end(StreamChunk.reasoning). Reasoning is not written to the transcript as text; an assistant tool-call turn keeps Anthropic’s signed or redacted thinking blocks in the newMessage.reasoning, which the Anthropic provider sends back unmodified (it survives checkpoints and resumes). The UI reducer keepsUIMessage.reasoning,toUIMessageStream()emitsreasoning-start/reasoning-delta/reasoning-end,lousho acpsendsagent_thought_chunk,lousho chatprints reasoning dimmed, andchatspans carrylousho.usage.reasoning_tokens. Cassettes record the reasoning chunks. See docs/reasoning.md. - Tools know their session (LOU-D23.2):
ToolExecutionContext.sessionIdis now set whenever the run has one:AgentExecutor.execute({ sessionId }),send({ sessionId }), everyagent.session()turn (the session id; a checkpointed turn’s own<id>.turn-<n>, as hooks see it), approval resumes (the paused run’s), and sub-agents (<parent session>/<task tool call id>). A session turn’s run now carries the session id even without a checkpoint store (no checkpoint is written), so hooks and theinvoke_agentspan’s conversation id see it too. - Hardened Slack and Discord channels (LOU-P5.2):
approvers(a list of platform user ids, or(user: { id, name?, roles? }, { toolName, input, sessionId }) => boolean | Promise<boolean>) says who may click Approve / Deny (a refused click gets an ephemeral “not allowed” and the approval stays pending);onError(error, { channel, stage, sessionId? })on both channels, onmountChannels()and ondefineChannel()receives failures after the acknowledgment (reply delivery, a failed turn, an approval continuation; defaultconsole.errorwith channel, stage, session and SDK error code, never a token) and a failed turn tells the user so;mountChannels({ onDecision })reports who decided;parsegets a thirdctxargument (ChannelContext: pending approval, session id,hasSession) and a{ decision }may carryinboundandapprover. Pending approvals now resolve from the click itself and the Slack ‘active thread’ check asks the session store, so both survive a restart. Slack: the clicked message is replaced with the outcome (buttons removed) and direct messages (message.im, scopeim:history) are sessions keyed on the DM channel. See docs/channels.md. - Resumable sub-agent tasks (LOU-Y6): every
taskresult ends with ataskId(the footer is now[sub-agent '<name>': N step(s), finish reason '<reason>', taskId 'task_1']; a remote one gains, taskId '...'too), andtasktakes optionaltaskIdandmode: 'new' | 'resume' | 'fork'(defaultnewwithout ataskId,resumewith one).resumecontinues that sub-agent with its transcript and the new prompt as the next user turn;forkstarts a new task (newtaskId) from a copy of it and leaves the original alone. Conversations are kept per lead session in the agent’sstore.sessions(newSubagentOptions.sessions, set bycreateAgent({ store })) for lead runs with asessionId(checkpointedsend()and session turns), under a key hashed from the lead session and the taskId, so they survive a restart and cannot be reached from another session; other runs keep them in memory for the run. Background tasks share the ids (a background task’s id is nowtask_<n>counted with the synchronous ones): a finished one can be resumed, a running one is refused with the newLOUSHO_SUBAGENT_TASK_BUSY; an unknown, foreign or other sub-agent’s id gets the newLOUSHO_SUBAGENT_TASK_NOT_FOUND, both as the structured tool error. An approval inside a resumed child pauses and resumes the lead as before (streamed or not), and a resumed child’s usage counts toward the lead like a fresh call.remoteAgent()tasks resumed bytaskIdcontinue in the same remote session (LOU-Y7.2;RemoteSubagent.run()takessessionIdandtaskId); remote approvals are still not proxied, but a task whose remote run paused can be continued once the approval was decided on the remote agent. The model-facingtaskdescription and the sub-agents prompt block say how to continue a task. See docs/sub-agents.md#continuing-a-task. - Agent-directory and spec builds keep optional peers external, and the node server runs spec cron triggers (LOU-P8.3):
lousho build --target=node-server|dockerleaves every optional peer of the SDK (read from itspeerDependenciesMeta) out of the bundle, replacing the hand-keptdockerode/ollama-ai-provider-v2list, so a build no longer needs peers the agent does not use; a spec’s{ type: 'cron' }triggers now start on the built server (and are validated at build time), as on the Cloudflare Worker target. See Deployment. - Agent fingerprint on resume (LOU-W9.2): every checkpoint and approval snapshot now carries
agentFingerprint(new typeAgentFingerprint: the model id, each tool’s name with a hash of its JSON input schema, a hash of the instructions, and a SHA-256 prefix over them, via Web Crypto; functions are ignored), and a resume compares it with the resuming agent’s before any model call or tool runs. New optiononAgentDrift: 'warn' | 'error' | 'ignore'(typeAgentDriftMode) oncreateAgent(),AgentExecutor.execute()andresumeAfterApproval():'warn'(default) emits the newagent.driftevent (AgentDriftEvent:model?,toolsAdded,toolsRemoved,toolsChanged,instructions) and aconsole.warn,'error'rejects with the newLOUSHO_AGENT_DRIFTand leaves the checkpoint (or the pending approval) untouched. A pending tool call whose tool no longer exists always rejects with the newLOUSHO_RESUME_TOOL_MISSING. Works for crash resume,session.resume()and approval resume, streamed or not; old checkpoints and snapshots have no fingerprint and resume unchecked. A sub-agent’s nested approval snapshot carries its own fingerprint, but only the top-level agent is compared.resumeAfterApproval()’s options takecurrentAgent(set bycreateAgent()) so instructions can be compared. See docs/durable-execution.md#resuming-with-a-changed-agent. - Docker sandbox network allowlists are enforced through the credential broker (LOU-X12.2):
new SubprocessSandbox({ network: { allow }, broker })creates (or reuses, bynetworkName) an internal Docker network (Internal: true, labelcom.lousho.sandbox=egress), has the broker listen on its gateway address for that subnet only, and starts each container on it withHTTP_PROXY/HTTPS_PROXYpointing there, so the broker (allowlist: its rule hosts plusallow) is the container’s only route out. Newsandbox.close()stops the listener and removes the network it created. Newbroker.listen({ host, clients, allow? })adds a listener on another address that drops peers outside theclientssubnet; the broker still listens on loopback only by default. Enforced on Docker Engine 25.0.5+ for Linux with the agent on the same host; Docker Desktop, rootless Docker, a remote daemon, a non-internal reused network and older Engines are refused with the new codeLOUSHO_SANDBOX_EGRESS_UNSUPPORTED(no container is started). Without abroker,{ allow }still means no network. See docs/workspace-tools.md. - The Docker sandbox honors cancellation (LOU-U23):
SubprocessSandbox.run()takessignal(SandboxRunOptions). When it aborts, the container is killed and force-removed (errors from an already stopped or removed container are ignored) andrun()rejects with anAbortError, likeNoopSandbox; an already-aborted signal starts no container; a timeout uses the same cleanup.SandboxShellpasses the tool’s abort signal down, so a cancelledshellcall stops its container (it used to run until the timeout) and reportsaborted: true. See docs/workspace-tools.md. - Schedules on Cloudflare Workers (LOU-P9): the
cloudflare-workertarget writes the spec’s{ type: 'cron', cron, input, name?, timezone? }triggers (deduplicated) towrangler.tomlas[triggers] crons = [...], and the generated Worker exportsscheduled(controller, env, ctx), which runs every trigger whose expression equalscontroller.cronas an agent turn insidectx.waitUntil(), in sessionschedule:<name>(visible in the KV store when bound). A failing trigger is logged withconsole.error(name and error code) and never stops the others. The build fails withLOUSHO_SCHEDULE_INVALIDnaming the trigger for what Cloudflare would not fire: atimezone(crons are UTC), a seconds field or@dailyshortcut, a numeric day-of-week (Cloudflare counts 1 as Sunday). NewhandleScheduled(agent, schedules, controller, ctx)(root anddeploy-runtime-worker) wiresdefineScheduleschedules to a hand-written Worker’sscheduled().startSchedulesshares its fire step with it. See docs/schedules.md#on-cloudflare-workers. aiv6 and v7 as peers (LOU-D28d):peerDependenciesnow acceptai^4.3.19 || ^6.0.0 || ^7.0.0and@ai-sdk/openai/@ai-sdk/anthropic^0.0.42 || ^1.0.0 || ^3.0.0 || ^4.0.0(still optional), plus the new optional peerollama-ai-provider-v2^2.0.0 || ^3.0.0 || ^4.0.0, so a project on the currentaimajor installs without peer conflicts. Pairings:ai4 with@ai-sdk/*0.0.x/1.x andollama-ai-provider1.x;ai6 with@ai-sdk/*3.x andollama-ai-provider-v22.x/3.x;ai7 with@ai-sdk/*4.x andollama-ai-provider-v24.x. A missing provider package’sMissingPeerDependencyError.installCommandnames the version for the installedaimajor (npm install @ai-sdk/openai@^4.0.0onai7; unchanged onai4).OllamaProviderloadsollama-ai-provider-v2onai6/7; that package needs zod 4, which the SDK does not support yet, so its install hint says so and Ollama stays onai4 for now.lousho doctorchecks provider packages against the installedaimajor and flags a mismatched pair (e.g.ai7 with@ai-sdk/openai1.x) with the version to install; a missing or unsupportedaigetsnpm install ai@^7.0.0.lousho init/npm create lousho-agentscaffoldai@^7.0.0with@ai-sdk/openai@^4.0.0/@ai-sdk/anthropic@^4.0.0for OpenAI and Anthropic, and keepai@^4.3.19for Ollama (zod 4) and OpenRouter (@ai-sdk/openai2+ defaults to the Responses API, not yet verified there). The hint ofresolveProvider()’s rare synchronous missing-peer error lists the command for eachaimajor. See Installation.lousho add(LOU-D50):lousho add <name> [--registry <url-or-path>] [--dir <agent-dir>] [--yes] [--overwrite] [--dry-run]installs a tool, skill, channel, schedule or memory slot from a static JSON registry (an index plus one document per item that carries the file contents) into an agent directory as source you own;lousho add --listprints the index. The registry comes from--registryorLOUSHO_REGISTRY(there is no hosted registry yet). It prints the item’s permission manifest (hosts, env vars, filesystem, exec, whether its tools ask for approval) and the files first and asks for confirmation (--yesskips it; a non-interactive stdin without--yesrefuses;--dry-runwrites nothing). Paths must be relative, inside the folder of the item’s type and inside the agent directory (no.., absolute paths, drive letters, backslashes or symlink escapes), files and items have size caps, existing files need--overwrite, fetches time out and onlyhttp(s)and local paths are read; nothing from a registry is executed anddependenciesare only printed as annpm installline. New error codesLOUSHO_REGISTRY_UNREACHABLE,LOUSHO_REGISTRY_ITEM_NOT_FOUND,LOUSHO_REGISTRY_INVALID,LOUSHO_REGISTRY_UNSAFE_PATH,LOUSHO_REGISTRY_FILE_EXISTS. See docs/registry.md.- Checkpoint history for
KVCheckpointStoreand Agent Forge’s file store (LOU-D43.2):KVCheckpointStore(andKVStore, with the newhistoryLimitoption) keeps the bounded per-session historymemoryStore()keeps, as one index key (<prefix><sessionId>#history) and one key per entry (<prefix><sessionId>#history/<id>) next to the unchanged latest-checkpoint key, soagent.fork(),compareTrajectories()and time travel work for Worker-deployed agents; a store written before this change still loads. A save now makes onegetand twoputs more (historyLimit: 0turns the history off), and two concurrent saves of one session can lose an index update (KV has no transactions). Agent Forge’sFileCheckpointStorereads a partially written history file as empty and writes its files atomically. The history contract suite now runs againstKVCheckpointStoretoo. See docs/durable-execution.md#checkpoint-history. - Hook outcomes (LOU-X3): a
preToolCallhook may return{ deny: reason }(the call does not run; the model gets the samekind: 'denied'tool error as aneedsApprovaldeny, streams seetool.error, andonPermissionDecision/permission.decisionrecorddecision: 'deny'with the newhookfield andreason),{ result: value }(the call does not run;valueis its result) or{ input: args }(the call runs withargs, validated against the tool’s schema again; a mismatch is akind: 'validation'tool error naming the hook). ApostToolCallhook may return{ result: value }to replace the result the model sees. Pre-hooks run in registration order: the first deny or result skips the later ones, inputs chain. A replaced result is markedreplacedByHookon thetool.doneevent, theToolCallOutcome/tool-resultevent and the transcript’stoolmessagemetadata. Hooks still run first (after argument validation, before permission rules, tool guardrails andneedsApproval), so the approval sees the hook’s input; on a resumed approved call the pre-hooks run again and an{ input }that differs from the approved input is refused. Sub-agents inherit the outcomes. Hooks that return nothing type-check and behave as before;HookRegistry.runPreToolCall()now resolves to aPreToolCallDecisionandrunPostToolCall()to the name of the replacing hook (new typesPreToolCallOutcome,PostToolCallOutcome,PreToolCallDecision). A hook that throws still rejects the run. See docs/api-overview.md#hook-outcomes. lousho acp(LOU-Z6):lousho acp <spec.yaml|spec.json|agent-dir|agent.ts> [--model provider/model]serves an agent over the Agent Client Protocol (v1) on stdio, so Zed and other ACP editors can run it as an external agent; stdout carries only JSON-RPC lines (the agent’sconsole.loggoes to stderr). It implementsinitialize,session/new,session/prompt(text andresource_linkblocks) andsession/cancel, streamssession/updates (agent_message_chunk,tool_call,tool_call_update), askssession/request_permission(Allow / Reject) when a tool needs approval and streams the continuation, and maps finish reasons to stop reasons (max-stepstomax_turn_requests,budget-exceeded/lengthtomax_tokens,guardrailtorefusal, a cancel tocancelled). One ACP session is one SDK session. Anask_questionpause ends the turn with the question as the agent’s message; the next prompt answers it. A failed run answers a JSON-RPC error whosedata.codeis the SDK error code. The protocol core is exported asserveAcp(agent, { input, write, store? })(new typeServeAcpOptions), transport-independent. Not supported yet:session/load, the client’sfs/*andterminal/*methods, images, audio and embedded resources,planand thought chunks. See docs/acp.md.- Agent directories deploy and serve with their schedules and channels (LOU-P8.2):
lousho build <agent-dir> --target=node-server(anddocker; the path may also be--agent) builds a directory into a server whose entry callsresolveAgentDir()andcreateDeployedServer(agent, { schedules, channels }), so the deployed process starts theschedules/and mounts thechannels/under/channelsand logs which it found. The code files are pre-bundled by the existing tsup step intodist/agent/**.js(one ESM build sharing one SDK copy;dist/is marked ESM) and the rest of the directory is copied, so nothing needs a TypeScript loader at run time; spec builds are unchanged.lousho dev <agent-dir>mounts the directory’s channels and starts its schedules, swapping both on hot reload (old schedules stopped first);--no-schedulesstarts none. The deploy runtime also exportscreateAgent,resolveAgentDirandstoreFromEnv. The Cloudflare Worker target is unchanged (P9). See docs/agent-directories.md#deploy-it-with-lousho-build. - Route handler (LOU-P4):
createRouteHandler(agent, { basePath?, auth?, uiMessageStream? })returns{ GET, POST, handler }, Fetch handlers ((request: Request) => Promise<Response>) that serve the deployed session API underbasePath(default/api/agent; trailing slash accepted, other paths 404) from a Next.js App Routerapp/api/agent/[[...path]]/route.ts, SvelteKit+server.ts, Hono or Bun.serve, with no framework ornode:*import.authis a bearer token or a(request) => boolean | Promise<boolean>(default none: a public route needs one;GET /healthstays open).useLoushoAgent({ url, approvalsUrl })works against it (POST <basePath>with{ input, sessionId? },POST <basePath>/approvals/:id), anduiMessageStream: trueaddsPOST <basePath>/uiforuseChat.hasBearerTokeninsrc/server/fetchRoutes.tsis now exported for it. See docs/nextjs.md. - AI SDK UI stream (LOU-P1):
toUIMessageStream(run)maps a run’s events to the Vercel AI SDK’s UI message stream chunks (start,start-step,text-start/text-delta/text-end,tool-input-start/tool-input-available/tool-output-available/tool-output-error,finish-step,error,finishwith the AI SDK finish reason), andtoUIMessageStreamResponse(run, init?)returns the SSEResponse(data: [DONE], headerx-vercel-ai-ui-message-stream: v1) for a route handler, souseChatcan render a Lousho run. An approval pause orask_questionis adata-lousho-approvalpart (approvalId,toolCallId,toolName,input,kind?,question?); usage and the run’s own finish reason are in thefinishchunk’smessageMetadata.fromUIMessages(messages, { lastUserOnly? })converts theUIMessage[]useChatposts (text, image and file parts; others ignored) to anAgentInput. Own structural chunk types (LoushoUIMessageChunk,UIMessageLike, …), noaiimport and nonode:*, so it also runs on Workers; exported from the package root (a dedicated subpath can follow). See docs/ai-sdk-ui.md. - Approval policies (LOU-X8): a
needsApprovalfunction may return (or resolve to)'approve','deny','ask'or{ deny: reason }besides a boolean (trueis'ask',falseis'approve'; new typeApprovalOutcome), and gets a second argument{ toolName, toolCallId, sessionId?, messages }(new typeApprovalCheckContext). A deny does not pause: the call is not run, the model gets akind: 'denied'tool error with the reason, streams seetool.error, andonPermissionDecision/permission.decisionrecorddecision: 'deny'with the newreasonfield (norule); the audit entry is now reported once the call is decided (after tool guardrails) instead of before them. New helpersalways(),never()andonce({ per?: 'tool' | 'args' })(with typeApprovalPolicy):once()asks the first time a tool is called in a session and runs later calls once a human approved one (a rejection is not remembered;per: 'args'keys on the arguments’ hash). The memory is the approved call’stoolmessage (metadata.approval, set byresumeAfterApproval()and its streamed form), so it persists with sessions, checkpoints and approval snapshots and survives a resume in another process. Apermissionsdenyrule wins; analloworaskrule replaces the tool’s ask but not its deny (see Changed); the tool’s outcome applies when no rule matches. Existing boolean functions type-check and behave as before; a test assertingneedsApprovalwas called with exactly one argument now sees the context too. See docs/approvals.md#approve-deny-or-ask. - Approval continuations stream live (LOU-D32.2):
POST /chat/:sessionId/approvals/:id(node server, Cloudflare Worker,lousho dev) now streams the continuation fromagent.approvals.streamResolve()/streamAnswer()instead of replaying it after the turn ends;createAgentRunner’sapprove()/reject()/answer()(React, Vue, Svelte) read that SSE stream event by event (anApprovalOutcomeJSON from an older server still works), andlousho chatprints the continuation as it streams. The UI reducer maps a streamedtool.errornamedToolRejectedErrorto arejectedtool call. A second pause arrives asapproval.requested. The approvals route has always answered with SSE, so no client opt-in was added. session.compact()andsession.clear()(LOU-W8):await session.compact({ strategy?, protectedTokens?, contextWindow?, signal? })compacts the session’s transcript now, whatever its size, with the given strategy, elseagent.session({ compaction })(new option, the value ofcreateAgent({ compaction })), else by pruning old tool results; it saves the result through the session’s store and resolves to{ messagesBefore, messagesAfter, tokensBefore, tokensAfter, strategy, error? }.clear()(which already existed and forgot the conversation) now emitscontext.cleared(newContextClearedEventin theAgentEventunion) and rejects withLOUSHO_SESSION_AWAITING_APPROVALwhen a durable turn waits on an approval; it keeps the session id. Newsession.on(listener)receivescompaction.start/compaction.done(which gain an optionaltrigger: 'manual') andcontext.cleared. Both reject with the newLOUSHO_SESSION_BUSYwhile a turn is in flight;compact()also withLOUSHO_SESSION_TURN_PENDINGfor an interrupted durable turn.lousho chatgains/compactand/clear. See docs/compaction.md#compacting-a-session.- Discord channel (LOU-P6):
discordChannel({ publicKey, applicationId, botToken?, name?, fetch? })serves Discord’s HTTP Interactions endpoint throughmountChannels()(Web Crypto andfetchonly, Worker-safe, no gateway, no new dependency). It verifiesX-Signature-Ed25519/X-Signature-Timestamp(Ed25519 overtimestamp + body, 401 otherwise) and answersPINGwithPONG. A slash command (/ask prompt:<text>) is acknowledged at once with a deferred response; its session is keyed on guild + channel (+ thread), and the reply edits the original response, with text over 2000 characters continued in follow-up messages. A tool approval is posted with Approve / Deny buttons and resolved by the click; anask_questionis answered by the next/askin the channel. See docs/channels.md#discord. - Streaming resume after an approval (LOU-V14):
streamResumeAfterApproval(decision, approvalStore, toolRegistry, provider, options?, checkpointStore?)takesresumeAfterApproval()’s arguments and returns anAgentRunwhoseresultis whatresumeAfterApproval()returns. Its events arerun.start, the decided call’stool.start/tool.done(tool.errorfor a rejection), then the continuation’s events as in a freshstream()run; a further pause ends it withapproval.requested. Abort,enqueue()andsteer()work as on any run, and the approval can be resolved in another process.createAgent()agents getagent.approvals.streamResolve(decision, { signal })andstreamAnswer({ id, answer }, { signal })(a pause made in a session continues in it,run.doneafter the transcript is saved; a dynamic agent keeps the model the paused run used). Also new: theResumeRequesttype andresumeRequest(request)(resumeAfterApproval()with one object argument). Additive:resolve(),answer()andresumeAfterApproval()are unchanged. See docs/streaming.md#streaming-after-an-approval. - Credential broker (LOU-X12):
createCredentialBroker({ rules, allow?, allowPrivate?, port?, host?, pathFormScheme? })starts a host-side HTTP forward proxy (Node-only, on an ephemeral127.0.0.1port by default) and resolves to{ url, env, baseUrl(host), close() }.rulesmap a host (exact or*.suffix) to headers to inject, as strings or per-request functions, so a command the model runs can call an authenticated API without the token entering its environment: passbroker.env(HTTP_PROXY/HTTPS_PROXY/NO_PROXY, both cases) toNodeWorkspaceorSandboxShell’senv. Hosts outside the rules andallowget a 403 without a connection; hosts resolving to loopback, link-local or private addresses are refused unless inallowPrivate; a clientAuthorizationheader for a brokered host is replaced; hop-by-hop headers are dropped. Injection covers plain-HTTP requests and the/__broker/<host>/<path>form (forwarded tohttps://<host>/<path>, returned bybaseUrl(host)); HTTPSCONNECTtunnels are allowlist-checked and passed through untouched (no TLS interception).SubprocessSandbox’snetwork: { allow }still fails closed: routing containers through the broker is LOU-X12.2. New typesCredentialBroker,CredentialBrokerOptions,BrokerHeaderValue. See docs/workspace-tools.md#credential-broker. - Memory in agent directories (LOU-W6.3):
memory/*.ts|js|mjsfiles default-export a memory slot (defineMemory(), or the same options without aname; name = file name unless set). Unlike schedules and channels they are part of the agent:loadAgentDir()passes them tocreateAgent({ memory }),resolveAgentDir()lists them inmanifest.memory, and amemoryoverride is merged by name (the override wins a clash). A bad file fails with the newLOUSHO_MEMORY_INVALIDnaming the file. A directory withoutmemory/is unchanged. See docs/agent-directories.md#memory. - Channels in agent directories (LOU-P7.2):
channels/*.ts|js|mjsfiles default-export a channel (defineChannel()or a built-in factory; name = file name unless set);resolveAgentDir()returns them aschannelsandmanifest.channels, and a file that exports no channel fails with the newLOUSHO_CHANNEL_INVALIDnaming the file.createDeployedServer(agent, { channels })mounts them under/channelsnext to the chat routes.loadSchedules()andloadChannels()share one loader (schedule files that do not export a schedule now throw anSDKErrorwithLOUSHO_SCHEDULE_INVALID, same message).lousho devand the Cloudflare Worker target do not mount them yet. See docs/agent-directories.md#channels. serveMcpannotations (LOU-Z5.2):defineTool({ annotations })(readOnlyHint,destructiveHint,idempotentHint,openWorldHint,title; stored asmetadata.mcp.annotations) is sent intools/listverbatim, and a tool withneedsApprovalis advertisedreadOnlyHint: false, destructiveHint: true(never read-only). An unannotated tool sends none. A Lousho agent consuming the server therefore runs read-only tools without an approval pause, pauses forneedsApprovalones and keeps asking for unannotated ones. The built-inread_file,list_dir,glob,grep,todo_read,current_dateandday_nametools are marked read-only. See docs/configuration.md#annotations.- Svelte store (LOU-P3): new
@lousho/build-ai-agent/sveltesubpath withloushoAgent(source, options?). It returns a Svelte store (subscribe(run) => unsubscribe, so$agentworks in Svelte 4 and 5) whose value is the same state as the React hook and the Vue composable (messages,status,pendingApproval,error,usage,lastEvent), plussend(),stop(),approve(),reject(),answer()andreset(). The run in flight is aborted bystop()and when the last subscriber unsubscribes. The store contract is implemented by hand: the entry imports nothing fromsvelte, so there is no new peer dependency. See docs/svelte.md. - Per-run dynamic config (LOU-V15):
createAgent()’smodel,instructions/promptandtoolseach also take a function of the run,(ctx) => value | Promise<value>(new typesPerRun<T>andRunConfigContext={ sessionId?, input, metadata? }), resolved once at the start of each run and of each session turn, before the first model call.fallbackModels,retry,projectInstructions, memory, MCP tools, sub-agents, permissions, approvals and guardrails apply to the resolved values; a dynamic agent used as a sub-agent resolves with the task prompt asinput.session.send()/session.stream()gain themetadataoption (send()/stream()already had it, and now pass it to these functions too). A function that throws fails the run with the newLOUSHO_CONFIG_RESOLVER_FAILED(fieldnames the option,causeis the error); a session keeps its transcript. A run paused for approval stores itsctxand resolved model in the approval snapshot (agent.metadata.loushoRunConfig): resuming it uses that model and re-resolves the tools with the samectx; a crash resume from a checkpoint resolves withinput: []. Static options behave and type as before.SessionRunner/SessionStreamRunnertake an optional fourthcallargument (new typeSessionTurnCall). See docs/api-overview.md#dynamic-config. - Vue composable (LOU-P2): new
@lousho/build-ai-agent/vuesubpath withuseLoushoAgent(source, options?)for Vue 3. It takes the same sources and options as the React hook (either may be aref, read when a turn starts) and returnsmessages,status,pendingApproval,error,usageandlastEventas read-only refs, plussend(),stop(),approve(),reject(),answer()and a newreset()(aborts, forgets the in-process session, clears the chat). The run in flight is aborted bystop()and when the owning scope is disposed (onScopeDispose: component unmount,effectScope().stop()).vue(^3) is an optional peer dependency, loaded by no other entry. To share the logic, the reducer and the stream parser moved fromsrc/react/to a framework-neutralsrc/ui/, together with the run logic extracted from the React hook (createAgentRunner()); the./reactexports are unchanged, and the reducer gains an additiveui.resetaction. See docs/vue.md. - One flag parser for the
loushoCLI (LOU-U21): every command (dev,chat,eval,build,mcp,doctor,studio;initalready did) parses its flags withnode:utilparseArgsin strict mode throughsrc/cli/args.ts.--flag valueand--flag=valuework everywhere,--ends the flags,-h/--helpprints the usage and exits 0, and an unknown flag, a flag without its value or an extra argument fails withLOUSHO_CONFIG_INVALIDand the command’s usage as the hint. Behaviour change (migration):dev,studio,mcp,buildanddoctorused to ignore unknown flags, and a flag missing its value could take the next flag or a prefix match (--portx 1read as--port); they now fail with exit 1 and the usage line, so remove typos from scripts. A value that starts with-must be written--flag=-x(a bare--flag -xis a missing value);--portmust be an integer from 0 to 65535 fordev,studioandmcp;dev,mcpanddoctorreject a second path;studio --prod --devis rejected instead of preferring--prod.lousho devandlousho studioparsing moved frombin/lousho.jsintosrc/cli/dev.ts/src/cli/studio.ts(parseDevArgs,runDev,parseStudioArgs,runStudio). See docs/cli.md. sqliteMemory(store, { maxItems? })(LOU-W6.2, exported from@lousho/build-ai-agent/sqlite) keepsdefineMemoryslots in the agent’sSqliteStorefile: a newmemory_itemstable (migration 3, one JSON array per scope key, with the same serialization,maxItemsand query rules asfileMemory). An existing database gains the table on open and keeps its sessions and checkpoints. The memory provider contract tests now run against the in-memory, file and SQLite providers. See docs/memory.md#sqlite.- Slack channel (LOU-P5):
slackChannel({ signingSecret, botToken, name?, fetch? })(new export, with typesSlackChannelOptions,SlackChannelEvent,SlackThread) serves a Slack app’s Events API and interactivity requests throughmountChannels(). It verifies thev0request signature with Web Crypto (nonode:*import; the timestamp and header checks are shared withverifySlackSignature()), answers theurl_verificationchallenge, and acknowledges every request before the turn runs. Each Slack thread is one session (team + channel + thread rootts): a mention starts or continues it, and later messages in a thread the bot was mentioned in continue it. Retries (X-Slack-Retry-Num) and bot messages are skipped. Replies go to the thread withchat.postMessageoverfetch; a tool approval is posted as Approve / Deny buttons whose click resumes the session, and anask_questionas text answered by the next thread message. The channel contract gains two additive hooks for this:parse(req, respond)may callrespondto answer the request before the turn runs, and may return{ decision }(new typeChannelDecision;ChannelApprovalDecisionnow lives indefineChannel.ts, same public export) to resolve a pause instead of starting a turn. See docs/channels.md#slack. - Remote sub-agents (LOU-Y7):
remoteAgent({ url, auth?, name?, description?, headers?, fetch? })(new export, with typesRemoteAgentOptionsandRemoteSubagent) makes a deployed agent usable wherever a local sub-agent is: increateAgent({ subagents })records andSubagentCatalog.resolve(), delegated to by thetasktool and bybackground: truetasks. Each task opens a fresh session on the remote agent overPOST <url>/chat(bearerauth: a string or a function, called per task), reads the SSE stream to the end and returns the remote agent’s final text; the lead run’s abort signal aborts the request. Failures (network, 401 or other non-2xx, malformed stream, remote run error) are structured tool errors with the new codeLOUSHO_REMOTE_AGENT_FAILED, and the token never appears in errors or events; a remote run that pauses for approval fails withLOUSHO_SESSION_AWAITING_APPROVALnaming the remote session (proxying approvals is a follow-up). Worker-safe (fetchonly). See docs/sub-agents.md#remote-sub-agents. - Eval against a deployed agent (LOU-D47):
lousho eval --url <baseUrl> [--token <bearer>](token also fromLOUSHO_EVAL_TOKEN) runs every case of the trajectory eval files against a deployment (node server or Cloudflare Worker) instead of the in-process agent: one remote session per case overPOST /chat { sessionId, input }, the SSE stream read torun.doneand turned into the usual result (tool calls, reply, finish reason, steps, usage), so checks, scorers,t.judge(), the summary and--junitwork unchanged. A check that needs data the stream lacks (maxTokenswithout usage) fails and names what is missing. Programmatic equivalent:defineEval({ target: remoteTarget({ url, auth?, fetch? }) })(newremoteTarget(),EvalTarget;agentis now optional when atargetis given, andAgentSourcealso accepts any{ send() }).--urlwith--record/--replay/--driftis rejected withLOUSHO_CONFIG_CONFLICTING_OPTIONS; an unreachable deployment, a non-2xx answer or a truncated stream fails the case with the newLOUSHO_REMOTE_REQUEST_FAILED, and 401 withLOUSHO_REMOTE_UNAUTHORIZED. The token never reaches output or reports. See docs/evals.md#run-evals-against-a-deployment. - Schedules (LOU-P8):
defineSchedule({ cron, timezone?, name?, prompt | run })declares a cron schedule (apromptsent to the agent as a new turn, orrun({ agent, firedAt, name })); an invalid cron expression, or not exactly one ofprompt/run, throws the newLOUSHO_SCHEDULE_INVALIDat definition time. An agent directory’sschedules/*.ts|js|mjsfiles default-export one each (name = file name unless set);resolveAgentDir()returns them asschedulesandmanifest.schedules.startSchedules(agent, schedules, { now?, setTimer?, onError? })runs them with injectable clock and timers, isolates errors, skips a fire while the previous one runs and returnsstop();createDeployedServer(agent, { schedules })starts them on listen and stops them on close. The Cloudflare Worker target is not covered yet. See docs/schedules.md. - Channels (LOU-P7):
defineChannel({ name, verify?, parse, reply, onApproval?, sessionId?, stream? })describes one surface:verifyauthenticates a request (falseor{ ok: false }answers 401),parsemaps it to{ sessionKey, input, metadata?, replyTo }(ornullto acknowledge it without a turn),replydelivers the agent’s reply (withstream: true, also partial text as the model writes) andonApprovalrenders a pause (default: a text prompt throughreply).mountChannels(agent, channels, { store, basePath })returns a framework-free(req, res)handler, in the style of the/chatroutes, that servesPOST <basePath>/<name>(verify -> parse -> a turn in session`${name}:${sessionKey}`-> reply; turns of one session run one at a time) andPOST <basePath>/<name>/approvals/<id>({ approved, note? }or{ answer });handler.resolveApproval()decides a pause from code (e.g. a button callback) and replies through the same channel. Built in:httpChannel()(JSON{ sessionKey, input }in,{ sessionId, text, finishReason, approval? }out) andwebhookChannel({ secret })(WebhookTriggerAdapter’s behaviour; the adapter now delegates its auth and parsing to it, unchanged for callers). Slack and Discord channels are LOU-P5 / LOU-P6. See docs/channels.md. - Cloudflare Worker parity (LOU-D51): the
cloudflare-workertarget now serves the same API as the node server:POST /chat { sessionId, input }streamed as SSE, history per session,GET /chat/:sessionId,POST /chat/:sessionId/approvals/:idandGET /health; the deprecatedPOST /chat { message, sessionId? }still works and still checkpoints undersessionId.LOUSHO_API_TOKEN(a Worker secret:wrangler secret put LOUSHO_API_TOKEN) makes every route except/healthrequireAuthorization: Bearer <token>(constant-time compare). NewKVStore(kvBinding, { prefix?, ttl? })(src/deploy/kvStore.ts) is anAgentStoreon a Workers KV namespace:sessions/<id>(transcript JSON, bytes as{ "$bytes" }),checkpoints/<id>(KVCheckpointStore, which gains an optional TTL) andapprovals/<id>, with optional per-kind TTLs; the Worker builds it from the existingAGENT_CHECKPOINTSbinding, and keeps sessions in the isolate’s memory without one. An approval paused on one request can be decided by another request on a new isolate. The routes are now Fetch-native (src/server/fetchRoutes.ts:Requestin,Responseand aReadableStreamSSE body out, bearer auth with Web Crypto);src/server/chatRoutes.tsadapts them tonode:httpforlousho devand the node server, andsrc/server/bearerAuth.tsis gone. The built Worker runs acreateAgent()agent, so the build swaps its Node-only imports forsrc/deploy/shims/node.worker.ts, and thenode:leak check now also catches bare builtin specifiers; the bundle grows to about 1.7 MB. The generatedwrangler.tomldocuments the KV binding and both secrets. Thewrangler devtest now streams a session turn through workerd with a KV binding and a token. See docs/deployment.md#bindings-sessions-and-the-api-on-workers. - Steering (LOU-V10):
run.steer(input)onAgentRun(fromagent.stream(),session.stream()andAgentExecutor.stream()), andInputQueue.steer(input)for non-streaming callers, redirect a running run to new user input. If a model call is in flight and has not emitted text or tool calls yet, it is aborted with a per-call signal (not the run’s), its partial output is discarded, the input is appended as a user message and the model is called again (applied: 'immediate'; that step ends withstep.done'steered'and counts towardmaxSteps; a provider that ignores the signal is not waited for). Once the model has emitted output, the input waits for the next safe point likeenqueue()('queued'); after the run has finished,appliedisfalse. Tool calls already running finish and keep their results; calls of that turn not started yet get the usual “cancelled before it ran” result. It returns{ id, applied, joined }(new typeSteerResult;joinedis a promise of whether the input reached the transcript). New eventinput.steered{ id, text, mode: 'immediate' | 'queued' }(InputSteeredEvent, additive, schema v1), followed by the existinginput.applied. With checkpointing, the input is checkpointed when it is taken, before the model is called again, and the discarded partial turn is never checkpointed.agent.session({ turnPolicy: 'steer' })makes asend()/stream()made while a turn runs steer that turn ('queue'and'wait'are unchanged).AgentRungains the requiredsteer()member, so a hand-writtenAgentRunobject must add it. See docs/streaming.md#steering and docs/sessions.md. aiv6/v7stream()(LOU-D27): the built-in providers’stream()(and soagent.stream()) now works onaiv6 and v7 as well as v4, through the LOU-D26 compatibility layer (streamCompat()): the request is mapped as forgenerate(), and either major’sfullStreamis read into the sameStreamChunks: text deltas, tool calls (the whole call, or one assembled fromtool-input-start/-delta/-endwhen the model sends none), and afinishchunk with the finish reason and usage, including cached and reasoning tokens. Anerrorpart now rejects the stream with its error on both majors (on v4 the step used to end with finish reasonerrorand the partial text), anabortpart with the signal’s reason; reasoning deltas are dropped until a reasoning chunk type exists (LOU-V13).StreamChunk/StreamResultare unchanged. The protectedAiSdkProvider.convertMessages()now returns our structuralAiSdkMessage[]instead ofaiv4’sCoreMessage[](which v6/v7 do not export) and is marked deprecated for LOU-D28; an override returningCoreMessage[]still compiles. Theai/@ai-sdk/*peer ranges still name v4 (LOU-D28). Tested on the realaiv7 (MockLanguageModelV4), withagent.stream()events asserted by one helper on v4 and v7. See docs/providers.md#vercel-ai-sdk-versions.spec.policyis enforced (LOU-X5):specToAgent()compiles the policy block intocreateAgent()options through the newcompilePolicy().requiresApproval(trueor a list of tool names) becomespermissions: [ask(...)];guardrails(max-length,secret-scan,regex,deny-topics,llm-judge, by name or{ name, ...options, on }) become input and output guardrails;limits,askQuestionandcompactionpass through.loadSpec()validates the known fields (an unknown guardrail name fails with the names available and a did-you-mean; unknown policy keys are still kept for the generators), andlousho doctor <spec>prints one line per policy block and flags unknown guardrail names. Behaviour change:requiresApproval: truenow actually pauses tool calls (finishReason: 'awaiting-approval'); before, a spec that set it ran its tools without asking, and a spec with a guardrail name outside the built-ins (for examplediff-size-cap) now fails validation. See docs/configuration.md#policy-policy.aiv6/v7generate()(LOU-D26): the built-in providers’generate()now works onaiv6 and v7 as well as v4, through a compatibility layer that detects the installed major (v5+ exportsstepCountIs). On v6/v7 it sends messages asModelMessages (tool calls withinput, tool results asoutput,mediaType),maxTokensasmaxOutputTokens, one step asstopWhen: stepCountIs(1), tools asinputSchemawithoutexecuteandresponseFormatas a JSON-modeoutput, and reads usage frominputTokens/outputTokens/totalTokens(cached and reasoning tokens from their details) into the sameGenerateResult.LLMProvider,GenerateOptionsandGenerateResultare unchanged, and v4 behaves as before.stream()on v6/v7 throws an error naming LOU-D27 until that ticket lands; theaiand@ai-sdk/*peer ranges still name v4 (LOU-D28). Tested against the realaiv7 (MockLanguageModelV4) next to the v4 contract tests. See docs/providers.md#vercel-ai-sdk-versions.- Queued follow-up input (LOU-V9):
run.enqueue(input)onAgentRun(fromagent.stream(),session.stream()andAgentExecutor.stream()) adds user input to a run that is still going; it joins the transcript at the next safe point (after the current step’s tool results, before the next model call) and the run continues as if the user had typed it, with one more step when it arrives during the final reply. It returns{ id, applied }:appliedisfalsewhen the run had already finished (send the input as a new turn, e.g.session.send()), otherwise a promise of whether it was applied. New eventsinput.queued{ id, text }andinput.applied{ id, step }(additive, schema v1). Non-streaming callers pass anInputQueue(new export) asExecuteOptions.inputQueueand call itspush(input). With checkpointing, a waiting input is saved right away at the end of the checkpoint (the LOU-U8 queued-input slot), so a crash does not lose it.agent.session({ turnPolicy: 'queue' })makes asend()/stream()made while a turn runs (or waits to start) join that turn instead of waiting for its own ('wait', the default, keeps today’s behaviour).AgentRungains the requiredenqueue()member, so a hand-writtenAgentRunobject must add it. Steering (aborting the in-flight model call to redirect) is the follow-up, LOU-V10. See docs/streaming.md#queued-input and docs/sessions.md. - Input and output guardrails (LOU-X4):
guardrails?: { input?, output?, tools?, onTripped? }oncreateAgent()andExecuteOptions(new typeAgentGuardrails). A guardrail is{ name, check(ctx) }(new typeIoGuardrail;ctxis{ kind: 'input' | 'output' | 'tool', text, messages, toolName?, args?, signal? }) returning{ ok: true }or{ ok: false, reason, action?: 'block' | 'rewrite', replacement? }. Input guardrails run on each new user message before the first model call; output guardrails on the final reply (in a streamed run, on every step’s text, before itstext.done); tool guardrails on a call’s arguments after the permission rules (skipped ondeny) and beforeneedsApproval. A block ends the run with the newfinishReason: 'guardrail'andresult.guardrail({ name, kind, reason, toolName? }, new typeGuardrailTrip), with no model call for an input block;onTripped: 'throw'rejects with the newGuardrailError(LOUSHO_GUARDRAIL_TRIPPED) instead. A rewrite replaces the text.stream()emits the newguardrail.tripped(beforerun.done) andguardrail.rewroteevents. Sub-agents run their parent’s guardrails, then their own. Built-ins:maxLengthGuardrail({ maxChars }),regexGuardrail({ name, pattern?, action?, replacement? })(defaults to the secret-scan patterns, now exported asSECRET_PATTERNS),denyTopicsGuardrail({ topics })andllmJudgeGuardrail({ model, instruction }). The patch guardrails (runGuardrails(),secretScanGuardrail, …) are unchanged; the new types are namedIoGuardrail*becauseGuardrailandGuardrailResultare theirs.spec.policy.guardrailsin agent spec files is not wired yet (LOU-X5). See docs/guardrails.md#input-and-output-guardrails. - Deployed
/chatupgrade (LOU-D14): thenode-serveranddockertargets (dist/server.js) now serve thelousho devprotocol:POST /chat { sessionId, input }streams the turn as SSE (data: <AgentEvent JSON>, thenevent: done) with history kept per session,GET /chat/:sessionId,POST /chat/:sessionId/approvals/:id({ approved, note? }or{ answer }) andGET /health; the single-turnPOST /chat { message }still works (now with aDeprecation: trueheader). The routes are one shared module,src/server/chatRoutes.ts, used bylousho devand the deployed runtime.LOUSHO_STORE(memory, the default, orsqlite:<path>, Node >= 22) picks where sessions live. Bearer auth: setLOUSHO_API_TOKEN(or pass{ auth: { token } }as the new optional third argument ofadapter.scaffold(); the variable wins) and every route except/healthrequiresAuthorization: Bearer <token>(constant-time compare,401JSON otherwise); treat it as required for anything not on localhost. Thedockerimage is now based onnode:22-slim(wasnode:20-slim) fornode:sqlite.specToAgent(spec, { store })takes an optionalstore. The Cloudflare Worker target is unchanged (sessions over KV and SSE there are LOU-D51). See docs/deployment.md#http-api. lousho chat(LOU-D33): a terminal REPL for an agent.lousho chat <spec|dir|module> [--model provider/model] [--session id] [--store sqlite:<file>]loads the target likelousho devand reads one turn per line throughagent.session({ id, store }).stream(): text deltas as they arrive, each tool call as a dim[tool_name] {args}line followed by-> result,Approve <tool>(args)? [y/N]for a tool that needs approval, a numbered prompt with free text for anask_questioncall, and[usage]tokens and cost after each turn. Slash commands:/new,/model <provider/model>(rebuilds the agent, the session continues),/history,/quit. SDK errors print with their[LOUSHO_...]code and fix. The loop isrunChatRepl({ input, output, createAgent })insrc/cli/chatRepl.ts(testable with scripted lines and amockModelagent);loadTarget()is now exported fromsrc/cli/devReload.ts. See docs/cli.md#lousho-chat.- Memory slots (LOU-W6):
defineMemory({ name, description?, scope, provider, recall?, expose? })(new export) defines a named long-term memory, andcreateAgent({ memory: [slot] })wires it in. On the first model call of every run (send(),stream(), each session turn), apreGeneratehook recalls the slot’s newestrecall.maxItems(default 10) items into the system prompt as a<memory name="...">block (recall.query: 'last-input'passes the last user message as the provider query;recall.onSessionStart: falseturns recall off). The model getsremember_<name>({ text })andrecall_<name>({ query?, limit? })tools (expose: { remember?, recall? }, both on by default).scopeis'global','session'(keyed byagent.session({ id })’s id orsend()’ssessionId) or a function of{ sessionId, metadata }; the newSendOptions.metadatafeeds it. Providers implementMemoryProvider(list/add/remove); built in areinMemoryMemory()andfileMemory({ dir })(one JSON file per scope key), both bounded bymaxItems(default 1000, oldest dropped). See docs/memory.md. - Budgets (LOU-V6):
limits?: { maxTokens?, maxInputTokens?, maxOutputTokens?, maxCostUsd?, maxDurationMs?, maxSteps?, onExceeded? }oncreateAgent()andExecuteOptions(new typeRunLimits). Limits are checked before every model call (so after every tool batch) and after a model call that asks for tools, andmaxDurationMsaborts an in-flight model or tool call through the run’s signal. A tripped limit ends the run with the newfinishReason: 'budget-exceeded'andresult.budget: { limit, value, max, scope }(BudgetExceeded); tool calls of that step get a cancelled result and the run is checkpointed as finished, like'max-steps'.stream()emits the new typedbudget.exceededevent (BudgetExceededEvent) beforerun.done(additive, the event schema version stays 1).onExceeded: 'throw'rejects with the newBudgetExceededError(LOUSHO_BUDGET_EXCEEDED, no longer reserved) instead.limits.maxStepsis an alias ofmaxSteps(the stricter wins; alone it replaces the default of 10). Sub-agents’ usage counts toward their lead’s budget.agent.session({ id, limits })applies limits across a session’s turns: what the turns spent is saved asmetadata.sessionUsageon the transcript’s last message (new typesBudgetSpent,SessionBudget,SessionTurnOptions;ExecuteOptions.sessionBudgetcarries it to the run), somaxCostUsdper session holds across processes. See docs/configuration.md#budgets. - Stateful streaming dev chat (LOU-D32):
lousho devserves one session per browser tab.POST /chatwith{ sessionId, input }streams the turn as SSE (data: <AgentEvent JSON>per event, thenevent: done) throughagent.session({ id, store }).stream(input)over an in-processmemoryStore()(or the agent’s ownstore, when a module’screateAgent()options oroverridesset one);GET /chat/:sessionIdreturns the transcript and the pending approval;POST /chat/:sessionId/approvals/:idwith{ approved, note? }or{ answer }decides an approval or answers a question and streams the continuation (rebuilt from the continued run’s result, sinceagent.approvals.resolve()is not streamed). The chat page renders text deltas, tool calls with arguments and results, Approve / Reject and question buttons, errors andrun.doneusage and cost, with a “New session” button and the id kept insessionStorage. Sessions survive a hot reload and continue on the new agent. The{ message }body ofPOST /chatstill works and is deprecated (no session, JSONExecutionResult,Deprecation: trueheader). See docs/cli.md#lousho-dev and docs/streaming.md. - Agent Forge History tab (LOU-D45.2): the bottom drawer’s new History tab lists the loaded agent’s run steps (step, status, finish reason, tool calls, tokens, cost). “Edit and replay from here” appends a user message or edits one of the step’s tool results, forks the run at that step through
POST /runs/:id/fork, and shows the fork next to the original as a side-by-side trajectory with the diverging turn and the drift entries highlighted, refreshed when the fork finishes. See docs/agent-forge.md#the-history-tab. lousho devfor agent directories and TS agents, with hot reload (LOU-D31):lousho dev <path>takes a spec file (as before), an agent directory (loadAgentDir()) or a.ts/.jsmodule whose default (oragent) export is aSimpleAgentor acreateAgent()options object, detected by path type and extension (a bad path, extension or export fails withLOUSHO_CONFIG_INVALID/LOUSHO_SPEC_UNSUPPORTED_FORMAT). It watches the directory tree, or the module and its relative imports, and rebuilds the agent about 100 ms after a change with a?t=<time>cache-busted import, so edited tools reload; the new agent is built andready()before it replaces the old one, which is thenclose()d. A failed rebuild keeps the last good agent and reports the error in the console, in a banner on the chat page and in the newGET /dev/status({ kind, path, reloads, error }).startDevServer(path, port?, host?, { overrides?, debounceMs? })gains the options argument. Spec-file behaviour is unchanged; the chat is not streamed or per-tab (LOU-D32). See docs/cli.md#lousho-dev and docs/agent-directories.md#run-it-with-lousho-dev.- Multimodal input through the public API (LOU-V12):
agent.send()/agent.stream(),session.send()/stream(),t.send()in evals anduseLoushoAgent().send()take the new exportedAgentInput(string | ContentPart[] | Message[]): a string or parts become one user message, aMessage[]is passed through (in a session it is appended to the transcript). Checkpoints,agent.resume()and queued input hold the parts like any message. The React user bubble shows the text with an[image]/[file]marker per non-text part.SqliteStoresessions and checkpoints saveUint8Arrayparts as{ "$bytes": "<base64>" }, asFileSessionStoredoes. Finishes LOU-V11 (its note thatagent.send()/session.send()take no parts is out of date). See docs/providers.md#multimodal-input. - Built-in
ask_questiontool (LOU-X9):createAgent({ askQuestion: true })(off by default) oraskQuestionTool()gives the agentask_question({ question, options?, allowFreeText? }). A call pauses the run through the approval mechanism, so it waits durably like any approval (sessions,memoryStore()/SqliteStore, a restart between question and answer). The pending record gains optionalkind: 'question'andquestion: { text, options?, allowFreeText? }(new typesApprovalKind,ApprovalQuestion;describeApproval()andASK_QUESTION_TOOL_NAMEare exported), and so do theapproval.requestedevent and the React hook’spendingApproval. The newagent.approvals.answer({ id, answer })(the same asresolve({ id, approved: true, note: answer })) continues the run with{ answer, option? }as the tool result (option: index of the matching option);approved: falsegives the model akind: 'rejected'error saying the user declined to answer.useLoushoAgent()getsanswer(text). Theapprovecallback may now return a string (ApproveToolCallreturnsboolean | string), which approves with that note, so code can answer questions. A tool run after an approval sees the decision’s note asctx.approval.note(new optionalToolExecutionContext.approval). See docs/approvals.md “Asking the user a question”. - Agent Forge time travel API (LOU-D45): Agent Forge’s
FileCheckpointStorekeeps the bounded per-session checkpoint history (history(),historyLimitoption,delete({ keepHistory })) with the same semantics asLocalStorageCheckpointStore, checked by the SDK’s history contract suite. The runtime control server gainsGET /runs/:id/history(each step’s status, save time, finish reason, tool calls, tokens and cost),POST /runs/:id/fork({ fromStep, patch?: { toolResult?, appendInput?, businessState? } }, forks withAgentExecutor.fork()and starts the fork as a run of its own) andGET /runs/compare?a=&b=(compareTrajectories()). Fixed on the way: Agent Forge’s chat transcript compiles against multimodalMessage.content(LOU-V11) again, keeping each message’s text. See docs/agent-forge.md#time-travel. - Fork and replay (LOU-D44):
AgentExecutor.fork({ sessionId, fromStep, newSessionId?, checkpointStore, patch? })takes the newest entry offromStepin the session’s checkpoint history, appliespatch(messages(messages),businessState,toolResult: { toolCallId, result }replaced in place so the transcript stays provider-valid,appendInputqueued as a user message) and saves it as the'in-progress'checkpoint ofnewSessionId(default<sessionId>.fork-<n>), leaving the original session untouched; it returns{ sessionId, step, checkpoint }, andexecute({ sessionId, checkpointStore, input: [] })continues the fork. A missing step throws the newLOUSHO_CHECKPOINT_NOT_FOUNDcode.createAgent()agents gainagent.fork(sessionId, { fromStep, newSessionId?, patch? })overstore.checkpoints(resume withagent.resume()).compareTrajectories(a, b)compares two checkpoints or transcripts step by step: each run’s steps (text, tool calls and results), the first diverging step and thelousho eval --driftdiff. New typesForkOptions,ForkPatch,ForkResult,TrajectoryComparison,TrajectoryStepandDriftEntry. See docs/durable-execution.md#fork-and-replay. - Multimodal message parts (LOU-V11):
Message.contentis nowstring | ContentPart[], where aContentPartis{ type: 'text', text },{ type: 'image', image, mimeType? }(anhttp(s)URL, adata:URL or aUint8Array) or{ type: 'file', data, mimeType, filename? }(new typesContentPart,TextContentPart,ImageContentPart,FileContentPart). The built-in providers map the parts ofusermessages to theaiv4 user-content parts: images go to the model with all four (theaiSDK downloads an image URL first for Anthropic and Ollama); file parts become a[file <name> (<mimeType>) not sent]text note with a one-timeconsole.warn, since the pinned peers (@ai-sdk/*0.0.x,ollama-ai-provider) cannot send files (a subclass setsacceptsFileParts = trueto send them).system,assistantandtoolmessages are sent as their text. The newtextOf(message)returns a message’s text (the string, or its text parts joined), and is whatestimateTokens()(each image or file part counts 1,000 tokens), compaction,recordReplay()fingerprints (cassette messages gain an optionalattachmentslist: each image or file as its URL or a digest of its bytes),mockModel(),MockLLMProvider, OpenTelemetry content attributes and the React reducer (ui.sendtakes parts too) now read.FileSessionStoresavesUint8Arrays as{ "$bytes": "<base64>" }and loads them back. Pass multimodal messages asAgentExecutor.execute({ input: Message[] });agent.send()/session.send()taking parts is LOU-V12. See docs/providers.md#multimodal-input. - Declarative permission policies (LOU-X2):
permissions?: PermissionRule[]oncreateAgent(),ExecuteOptionsandresumeAfterApproval()options. A rule is{ tool: string | string[] | RegExp | '*', when?: (args, { toolName, toolCallId, sessionId }) => boolean | Promise<boolean>, action: 'allow' | 'deny' | 'ask', reason? }; rules are checked in order for each tool call (after argument validation andpreToolCallhooks, beforeneedsApproval) and the first match decides:allowruns the call without approval even whenneedsApprovalwould ask,denygives the model a tool error with the newkind: 'denied'(ToolDeniedError, plusreason) and streamstool.error,askpauses for approval (or asksapprove). No match keeps today’s behaviour. New helpersallow(tools),deny(tools, reason?),ask(tools)and typesPermissionRule,PermissionAction,PermissionToolMatcher,PermissionContext,PermissionDecision,PermissionDecisionEntry,PermissionOptions. Audit log:onPermissionDecision(entry)gets{ toolName, toolCallId, decision: 'allow' | 'deny' | 'ask' | 'default', rule?: { index, reason? }, args?, at }per call (argsleft out underredactContent), and streams get the same entry as the new typedpermission.decisionevent (PermissionDecisionEvent; additive, the event schema version stays 1). Neither is produced unlesspermissionsoronPermissionDecisionis set. Sub-agents inherit the lead’s rules (checked before their own) and itsonPermissionDecision. See docs/approvals.md#permission-policies. - Compaction events and the
compactionoption (LOU-W3.2):agent.stream(),session.stream()andAgentExecutor.stream()emit two new typed events whenever the compaction hook compacts a request:compaction.start(strategy,tokensBefore,contextWindow,thresholdTokens) before the work andcompaction.done(strategy,tokensBefore,tokensAfter,prunedToolCallIds,summary?,error?: { message }) after it, inside the step and before the model call;erroris set when the strategy failed and the hook fell back. Additive: the event schema version stays 1.GenerateHookContextgains the optionalemit(event)(set in streamed runs only) that the hook uses.createAgent({ compaction })takestrue(the prune hook with its defaults) or{ strategy?, thresholdPercent?, contextWindow?, protectedTokens?, summarizer? }, wheresummarizer(a'provider/model'string or anLLMProvider) selectstwoPhaseStrategy()with that model;summarizertogether withstrategyis aConfigurationError.createAgent({ hooks })(new) takes anyAgentHook[], run before the compaction hook. See docs/compaction.md. - Connect MCP servers from config (LOU-Z4):
connectMcp(servers, { lazy?, onError?, logger? })(@lousho/build-ai-agent/mcpand the root) connects stdio (command/args/env) and streamable HTTP (url/headers) servers, lists their tools as<server>__<tool>descriptors and returns{ tools, close(), status() }. Every server connects up front (listing tools needs a connection);lazy(defaulttrue) reconnects on the next tool call afterclose()or a dropped connection.onError: 'skip'leaves a failing server out with a warning instead of rejecting.createAgent({ mcpServers })connects them on the newagent.ready()or the firstsend()/stream()and adds their tools; the newagent.close()disconnects them (both are no-ops withoutmcpServers).specToAgent()now connectsspec.mcpServersthe same way (closes TODO(LOU-D20.2));agent.mcpServersstill lists them.loadMcpTools()accepts any{ listTools, callTool }(McpClientLike). See docs/configuration.md#connect-mcp-servers-mcpservers-connectmcp. - Error codes with fixes (LOU-D2): every
SDKErrornow has a stablecode(LOUSHO_<AREA>_<NAME>, e.g.LOUSHO_CONFIG_MISSING_PROVIDER,LOUSHO_PEER_MISSING,LOUSHO_SPEC_INVALID,LOUSHO_SESSION_AWAITING_APPROVAL), a one-sentencehintand adocslink to its section of the new docs/errors.md; its message ends with a[code] hint (docs)line (detailis the message without it), andtoString()always shows it.ERROR_CODES/ErrorCode(new exports) are the registry, and a test keeps it, the codes used insrc/and docs/errors.md in sync. The plainErrors thrown bycreateAgent()(no model,instructions+prompt,sessionIdwithout checkpoints),resolveProvider()(bad spec, unknown prefix, missing API key, missing peer),AgentExecutor.execute()/stream()(missingprovider/agent/input, approval with noapprovalStore),resumeAfterApproval()(unknown approval), sessions (invalid id, corrupt file, second iteration, no stream runner),loadSpec()andspecToAgent()are nowConfigurationError/ValidationError/SDKErrorwith codes;MissingPeerDependencyErrorextendsSDKError. Existing message text is unchanged (the help line is appended).loadSpec()suggests the field a top-level typo meant (unknown field 'promt' (did you mean 'prompt'?)). See docs/errors.md. - Checkpoint history (LOU-D43):
memoryStore(),SqliteStoreandLocalStorageCheckpointStorekeep a bounded history per session: everysave()also appends the record to a ring (historyLimitoption, default 50,0keeps none; the oldest are dropped), and the new optionalCheckpointStore.history(sessionId, { limit? })returns{ step, savedAt, status, checkpoint }entries newest first.CheckpointStore.delete(sessionId, { keepHistory: true })keeps the history (by defaultdelete()clears it too),getCheckpointHistory(store, sessionId)reads it and returnsundefinedfor a store withouthistory(), andSqliteStoregains thecheckpoint_historytable (added when an existing database file is opened;prune()removes old entries). New typesCheckpointHistoryEntry,CheckpointHistoryOptions,CheckpointDeleteOptionsandMemoryStoreOptions.KVCheckpointStoreand Agent Forge’s file store keep no history yet (LOU-D43.2). This is the first step of time-travel from a past step. See docs/durable-execution.md#checkpoint-history. lousho eval --record / --replay / --drift(LOU-D46):--recordruns every eval case against its agent’s real provider throughrecordReplay()and writes one cassette per case next to the eval file (__cassettes__/<eval-name>/<case>.json, the normal cassette format);--replayruns every case from its cassette with no network, and a missing cassette fails the case with the--recordcommand to run. Plainlousho evalwithCIset replays the cases that have a cassette; withoutCIit is unchanged.--driftre-records into a temp directory and compares each case with its committed cassette (ordered tool names, JSON-normalized tool arguments, step count, finish reason; tokens with--drift-usage): the summary gets aDrift:table and each drifted case a softdriftassertion (a JUnit<system-out>note), or a gate failure with--strict. Existing eval files need no change: while a case runs,AgentExecutor.execute()wraps its provider for that case.EvalResultgainscassettes. Fixed on the way: trajectory and classic eval results now carry theirfile(it was read before the test ran and was always empty). See docs/evals.md#record-replay-and-drift.- OpenTelemetry GenAI metrics and cost (LOU-D48):
createOtelTraceExporter()(@lousho/build-ai-agent/otel) now also records the GenAI client metricsgen_ai.client.token.usage(histogram,{token}, one record pergen_ai.token.typeinput/output) andgen_ai.client.operation.duration(histogram,s, for every model call and tool call) through@opentelemetry/api’s metrics API: the globalMeterProvider, or the newmeteroption;metrics: falseturns them off. Without a registeredMeterProviderthey are discarded by OpenTelemetry’s no-op meter. Spans gainlousho.cost_usd(estimated USD: per step onchatspans, cumulative oninvoke_agentspans, absent when the price table does not know a model) andlousho.usage.estimatedoninvoke_agentspans (it was already onchatspans). AnyTraceExportersees the cumulative attributes:withSpan()sums the cost of finished child spans onto their parent. See docs/observability.md. - One
storeoption (LOU-D30):createAgent({ store })takes anAgentStore({ sessions?, checkpoints?, approvals? }, new export) and wires all three from it.SqliteStoreis one; the newmemoryStore()returns in-memory sessions, checkpoints and approvals, and docs/sessions.md shows the file-based combination (FileSessionStore,LocalStorageCheckpointStore,StorageServiceApprovalStore).agent.session({ id })then keeps its transcript instore.sessionsand checkpoints every turn instore.checkpoints;store.approvalsis the defaultapprovalStore.SendOptions.sessionIdmakessend()/stream()a durable run checkpointed instore.checkpoints(an error without one), so a one-shot durable job needs noAgentExecutor, andagent.approvals.resolve()keeps checkpointing it. The newagent.resume(sessionId)finishes an interruptedsend(..., { sessionId })run, or else the session’s pending turn (assession.resume()), and returnsnullwhen nothing is pending. A session’s ownstore/checkpointStoreand an explicitapprovalStorestill win, part by part. Withoutstore, nothing changes. - One tool-error shape (LOU-U14): every failed tool call reaches the model as
{ error, toolName, message, kind }(message capped at 2,000 characters, transcript message flaggedisError), whichever path failed:kindis'execution','validation'(plusissues),'not-found','rejected'(plusnote),'not-run','mcp'or'sandbox'. New exportstoolErrorResult(),ToolErrorResult,ToolErrorKindandToolErrorInput;McpToolErrorand the sandbox guard’s error (namedSandboxRequiredError, not exported) carry atoolErrorKind.kindis additive on thrown-error and validation results. The paths that built their own shapes changed, see Changed. See docs/tools.md. - Background sub-agents end with the lead run (LOU-Y4.2): when a run ends (any
finishReason, or an error), its background sub-agents still queued or running are cancelled, and the run resolves once their runs have stopped, so none of their events reachesonEventafterwards.withSubagentOptions(subagents, { awaitBackgroundOnFinish: true })waits for them instead (an aborted or failed lead still cancels them). The newresult.backgroundTaskslists the run’s background tasks as{ taskId, agent, status, elapsedMs, ... }(absent when it started none).createAgent({ subagentOptions: { maxConcurrent, awaitBackgroundOnFinish } })sets the same options (over any attached withwithSubagentOptions(), without changing thesubagentsvalue). The executor hook behind it is public:ExecuteOptions.onRunEnd({ result } | { error })is called exactly once perexecute()/stream()run, however it ends; an error it throws rejects the run. See docs/sub-agents.md. - Durable sessions (LOU-W9):
agent.session({ id, store, checkpointStore })(or astorethat carries both,{ sessions, checkpoints }, such as aSqliteStore) runs each turn withsessionId: '<id>.turn-<n>'and thatCheckpointStore, so it is checkpointed after every model response and tool result.session.resume()finishes a turn interrupted by a crash, a failed checkpoint write or aPropagatingToolError(also in a new process) without re-running recorded tool calls or model responses, adds it to the transcript assend()would and returns its result (nullwhen nothing is pending);session.pending()reports an unfinished turn andsession.discardPending()drops it.send()/stream()resume a pending turn before sending the new message. A turn paused for approval stays in its checkpoint until it finishes:resume()/send()throwSessionAwaitingApprovalError, andagent.approvals.resolve()continues it in the session. Sessions without a checkpoint store are unchanged. See docs/sessions.md. - Structured output (LOU-V4):
createAgent({ output: zodSchema })(andExecuteOptions.output) makes the final reply a JSON object validated by the schema.send(),run.resultandagent.approvals.resolve()resolve with it asresult.object, typedz.output<typeof schema>fromsend()andstream()ofcreateAgent()agents (SimpleAgent,AgentRunandExecutionResultgain a type parameter that defaults tounknown);result.textkeeps the raw JSON. The system prompt gets an## Output formatsection with the schema as JSON Schema, and each model call carries the newGenerateOptions.responseFormat: { type: 'json', schema? }hint, which theai-SDK providers map toexperimental_outputJSON mode. The reply is parsed (a code fence is tolerated) and validated; when invalid, one repair step sends the model the issues (it counts againstmaxSteps), and a reply still invalid ends the run with the newfinishReason: 'output-invalid', noobjectandoutputError: { message, issues }.run.donegains an optionalobjectfield (additive: the event schema version stays 1). See docs/structured-output.md. - Background sub-agents (LOU-Y4): the
tasktool takesbackground: true, which starts the sub-agent and returns{ taskId, status: 'running', agent }at once. Agents withsubagentsalso getagent_status({ taskId? })(queued/running/done/failed/cancelled/awaiting-approval, pluselapsedMs),agent_await({ taskId | taskIds, timeoutMs? })(the synchronoustaskresult, orstatus: 'timeout') andagent_cancel({ taskId }).withSubagentOptions(subagents, { maxConcurrent })bounds how many run at once per lead run (default 3; the rest queue). Background sub-agents inherit the lead run’s runtime andmaxSubagentDepth, and aborting the lead cancels them. Not yet: resuming one that paused for approval (it reportsawaiting-approvalwith itsapprovalId). A user tool namedagent_status,agent_awaitoragent_cancelnow conflicts withsubagents, liketask. See docs/sub-agents.md. - React hook (LOU-D15): new
@lousho/build-ai-agent/reactsubpath withuseLoushoAgent(source, options?).sourceis{ agent, sessionId? }(in process, viaagent.stream()oragent.session({ id }).stream()) or{ url, headers?, fetch? }(POSTs{ input }and reads the SSE or NDJSON event stream). It returnsmessages(text and tool calls with status, args and result),status(idle/streaming/awaiting-approval/error),pendingApproval,error,usage,lastEvent,send(),stop()(aborts; unmount aborts too),approve(note?)andreject(note?)(viaagent.approvals.resolve()in process, orPOST ${approvalsUrl}/${id}remotely). The framework-neutralreduceAgentEvents()reducer andparseEventStream()parser are exported from the same subpath.react(^18 || ^19) is an optional peer dependency. See docs/react.md. - Summarizing compaction (LOU-W3):
summarizeStrategy({ model, protectedTokens?, prompt?, maxSummaryTokens? })replaces the turns before the protected tail with one[Conversation summary]user message written bymodel(anLLMProvideror a"provider/model"spec), keeping system and pinned messages and each assistant turn together with its tool results; if the summary call fails it falls back to pruning and reportserror.twoPhaseStrategy()(the recommended strategy) prunes first and summarizes only when still above the threshold.pinMessage()/isPinned()and the new optionalMessage.metadata(never sent to providers) mark messages no built-in strategy compacts.CompactionStrategy.compact()may now be async, socompactMessages()returns a Promise;CompactionResult/CompactionInfogainsummaryanderror, andCompactionInputgainsthresholdTokensandsignal. A strategy that throws no longer fails the run: the hook reports theerrortoonCompaction. See docs/compaction.md. createAgent({ retry, fallbackModels })(LOU-V7.2):retry(withRetry()options, orfalse) retries failed model calls and defaults to{ maxRetries: 2 }for models given asprovider/modelstrings (or picked from the environment); aproviderinstance is wrapped only whenretryis set.fallbackModelsareprovider/modelstrings tried in order once the primary model’s retries are used up (withFallback([withRetry(primary), withRetry(fallback), ...])).agent.stream(),session.stream()andAgentExecutor.stream()report them as two new typed events,provider.retry(attempt,maxRetries,delayMs,error: { message, category? },provider) andprovider.fallback(from,to,error: { message }), for anywithRetry()/withFallback()provider. Additive: the event schema version stays 1.- Context compaction (LOU-W2):
createCompactionHook()returns anAgentHookthat, before a model call estimated abovethresholdPercent(default 0.9) of the model’s context window, replaces tool results older than the newestprotectedTokens(default 40,000) with a[pruned: <tool> result, N chars]marker, in the run’s transcript itself.compactMessages()compacts a conversation by hand;CompactionStrategy/pruneToolResultsStrategy()make the strategy pluggable. See docs/compaction.md. AgentSpec.mcpServers(LOU-D20): a validated map of MCP servers (stdiocommand/args/envor HTTPurl/headers) in agent spec files.loadSpec()reports a bad entry with its name,lousho doctorreads the validated field instead of the raw file, andspecToAgent()exposes the parsed servers asagent.mcpServers(connecting them is TODO(LOU-D20.2)). Existing specs are unaffected.createAgent()agents can pause for approval (LOU-D21): aneedsApprovaltool no longer fails the run with “requires approval but no approvalStore”. The run pauses (finishReason: 'awaiting-approval') in a per-agentInMemoryApprovalStore(or the newapprovalStoreoption), andagent.approvals.list()/agent.approvals.resolve({ id, approved, note? })continue it, in its session if it paused in one. Theapproveoption decides calls in code without pausing.- Tools own their contract (LOU-D22):
ToolDescriptorgains optionalinputSchema(zod) andexecute(args, ctx), which are now the canonical fields.defineTool()sets both and no longer callsai’stool(). Argument validation, the schema sent to the model, and tool execution read them first and fall back totool.parameters/tool.execute.ToolDescriptor.tool(theaiv4Tool) is now legacy: it is still built bydefineTool()and still accepted on hand-written descriptors this release, but new code should setinputSchemaandexecute.
Removed
- BREAKING:
validateTokenQuotas()(src/security/quotas.ts) and the SaaS-era typesSaaSContext,QuotaConfig,UsageStatsandQuotaValidationResult(LOU-D36) no longer ship from the package root. Nothing in the SDK called them. No replacement; copy the file into your project if you relied on it. - BREAKING:
ConfigManagerandSDKConfig(src/core/ConfigManager.ts) (LOU-D36) no longer ship from the package root or./core(which still exportsAgentBuilder). Nothing in the SDK used them. No replacement; copy the file into your project if you relied on it. - BREAKING: The formatters (LOU-D36)
getCurrentTS(),getTS(),formatDate(),safeJsonParse(),removeCodeBlocks(),findCodeBlocks(),checkApiKey()and the typesCodeBlock,CodeBlockError,CodeBlockResult(src/utils/formatters.ts) no longer ship from the package root. No replacement; copy the file into your project if you relied on it. - BREAKING: The JSON path helpers
getObjectByPath()andsetRecursiveNames()(src/utils/json-path.ts) (LOU-D36) no longer ship from the package root. No replacement; copy the file into your project if you relied on it. - BREAKING: The file extraction helpers
getMimeType(),getFileExtensionFromMimeType(),replaceBase64Content(),isBinaryData(),extractBase64Data(),createDataUri()andProcessFilesParams(src/utils/file-extractor.ts) (LOU-D36) no longer ship from the package root. No replacement; copy the file into your project if you relied on it. - BREAKING: The flows-ai converters (LOU-D35) are gone:
convertToFlowDefinition()andconvertFromFlowDefinition()(src/flows/converters.ts) no longer ship from the package root or./flows. There is no replacement: the flows-ai engine they targeted was never a dependency of this package andFlowExecutornever used them. Run flows withFlowExecutordirectly. - BREAKING:
MemoryManagerand its typesMemoryManagerConfig,StoreMemoryOptions,RecallMemoryOptionsandMemorySearchResult(src/execution/MemoryManager.ts) (LOU-D38) no longer ship from the package root. Nothing in the SDK used them (AgentExecutor,createAgent,resumeand the flows never called them; only their own test and theexecution/index.tsre-export). Migrate todefineMemory()andcreateAgent({ memory })(memory slots, see docs/memory.md). - BREAKING:
ContextBuilder,ContextBuilderOptionsandExecutionContext(src/execution/ContextBuilder.ts) (LOU-D38) no longer ship from the package root. Nothing in the SDK used them; they truncated by message count only. Migrate tocreateAgent({ compaction })(token-aware compaction, see docs/compaction.md). - BREAKING: The
retry()family (retry,retryOnError,retryWithTimeout,retryBatch,RetryableOperation,RetryOptions,RetryResult;src/execution/retry.ts) (LOU-D38) no longer ships from the package root. Provider calls retry through the provider wrappers; nothing else in the SDK used it. Migrate tocreateAgent({ retry, fallbackModels }), or copy the file into your project for general-purpose retries. - BREAKING: The
data/module (LOU-D39) is gone: the classesAgent,Session,Result,Memory,Attachmentand the repository interfacesBaseRepository,AgentRepository,SessionRepository,ResultRepository,MemoryRepository,AttachmentRepository,RepositoryCollection,RepositoryFactory,PaginationOptions,PaginatedResultno longer ship from the package root. They were donor-product data tables that nothing in the SDK used onceMemoryManagerwas removed. No replacement: unused. UseAgentConfig, sessions (agent.session()) anddefineMemory()for the SDK’s own concepts. - BREAKING:
@lousho/build-ai-agent/testingexports test utilities only (LOU-D39). The in-memory repository mocksMockAgentRepository,MockSessionRepository,MockResultRepository,MockMemoryRepositoryandMockAttachmentRepositoryare removed with thedata/module. No replacement: unused.mockModelandrecordReplayare unchanged. - BREAKING: The template engine (
TemplateManager,renderTemplate()and the typesTemplateFilter,TemplateContext,TemplateOptions,ITemplateManager;src/templates) (LOU-D37) no longer ships from the package root. Nothing in the SDK,lousho initor Agent Forge used it (the executor never rendered prompts through it; the scaffold templates are separate files undersrc/cli/init). No replacement: unused; use a template literal or your own renderer. nanoidis no longer a dependency (LOU-D35). Generated ids (agent, run, trace, memory ids) now come fromglobalThis.crypto.randomUUID()via an internalnewId(), so they are UUIDs instead of 21-character nanoids. Nothing public depended on the old format.finishReason: 'max-steps'(LOU-U19): a run that exhaustsmaxStepswhile the model still wanted to continue (last turn ended in tool calls) now resolves withfinishReason: 'max-steps'instead of the stale last-turn reason (usually'tool_calls'), onExecutionResult, thefinishevent andrun.done. Resumed runs countinitialStepstoward the budget. A run that finishes naturally within the budget is unchanged. Code that treated'tool_calls'as “hit maxSteps” should check'max-steps'instead (the evalcompleted()message and the MCP server’s agent tool do).
Docs
- README revamped into a short front page (LOU-D52); the details it dropped moved to
docs/(new pages: tools, approvals, providers, cli, flows, guardrails, utilities).
Fixed
- On
ai5+ the providers send images asfileparts with an imagemediaType(image/*when unknown) instead of the deprecatedimagepart, which madeai7 log a deprecation warning for every image.ai4 still getsimageparts. - OpenRouter on
@ai-sdk/openai2+, and the Ollama base URL (LOU-D28f, behaviour fix):OpenRouterProvidernow builds its model withprovider.chat(modelId)(Chat Completions, the only API OpenRouter implements) instead of the bare call, which targets the Responses API from@ai-sdk/openai2 on; no change onai4.OllamaProviderappends/apito a base URL that has no path (http://host:11434orhttp://host:11434/), the formollama-ai-providerandollama-ai-provider-v2expect, so a configured bare host no longer hits/chatoutside/api(before,getModels()hit<baseURL>/api/tagsbut chat requests went to<baseURL>/chat); a URL ending in/apior with any other path is used as it is, andgetModels()no longer doubles/apifor a base URL that has it.lousho init(andcreate-lousho-agent) now scaffolds OpenRouter projects onai@^7.0.0with@ai-sdk/openai@^4.0.0.
Changed
- zod 3 or zod 4 (LOU-D29): the
zodpeer is now^3.25.76 || ^4.0.0. Schemas of either major (includingzod/v4on zod 3.25 andzod/v3on zod 4) work indefineTool({ input }), structuredoutputand argument validation: zod 4 schemas are sent to the model asz.toJSONSchemaoutput (also toaiv4, whose own converter only reads zod 3), zod 3 schemas through theaiSDK’s converter as before, and both are parsed with their ownsafeParse.defineToolalso takes any Standard Schema that exposes~standard.jsonSchema; tool arguments validate with any Standard Schema’s~standard.validate. The SDK’s own schemas (AgentSpec, registry documents, built-in tools, MCP tool conversion) run on whichever major is installed, and issue messages read the same on both (zod 4’sInvalid input: expected string, received undefinedis reported asRequired).lousho initkeeps zod 3 for theai4 scaffold (Ollama), whose packages peer on zod 3. Ollama onai6/7 (ollama-ai-provider-v2, which peers on zod 4) is no longer blocked by the SDK’s zod peer. CI runs the type check, build and suite on zod 4 too. Migration:DefineToolOptions,DefinedToolandToolDescriptor.inputSchemaare typed with the new exportedStandardSchemaV1(parsed type:InferSchemaOutput) instead of zod 3’sZodTypeAny; code that called zod methods ondescriptor.inputSchemaneeds a cast. Thetasktool’s error for an unknown sub-agent now readsUnknown sub-agent "x". Expected one of: 'a', 'b'.createAgent({ output })is still typed with the installed major’sZodType. - Slack and Discord approvals are starter-only by default (LOU-P5.2, behaviour change): without
approvers, only the user who started the turn (the Slack message author, the Discord command’s user) can approve or deny a tool call; before, anyone who could see the message could. Migration: passapprovers: () => truefor the old behaviour, or a list of user ids / a function for a team. Approval button values (Slack) and custom ids (Discord) now carry the starter (<starter>:<approval id>), so buttons posted before the upgrade carry no starter and only anapproverslist or function can approve them. - Package entries share one copy of each module (LOU-D42): tsup code splitting is on, so
@lousho/build-ai-agent,/hooks,/tools,/mcpand the rest import shared chunks instead of each bundling their own copy.HookRegistry,SDKErrorand module-level registries are now the same object across entries (they were duplicated, soinstanceofand singletons could disagree), anddist/shrinks from about 25 MB to 8.8 MB.instanceof SDKErrorandinstanceof HookRegistryalso hold between the ESM and CJS copies a mixed-format process loads (aSymbol.forbrand). See Installation. - A crash resume of a dynamic agent restores the run’s config (LOU-V15.2): checkpoints now carry
runConfig(thectxand model the run was resolved with, as approval snapshots already did), andagent.resume(id)/session.resume()/send(…, { sessionId })on an unfinished run use the saved model and re-resolvetoolsandinstructionswith the savedctx, instead ofinput: []and nometadata; a model chosen frommetadatano longer changes mid-run. Static agents are unchanged. - Resumed approved calls must run with the approved arguments (LOU-X3.2, behaviour change): after the pre-tool hooks ran on the resumed call, the arguments are compared (deep, key order ignored) with the approved ones whether a hook returned
{ input }or changedctx.argsin place, and a difference is refused with the existingkind: 'validation'error (“approved with different input”); a hook that rebuilds the same arguments in another key order is no longer refused. Migration: a hook that rewrites arguments (a redactor, a normalizer) must also run on the original run, so the approval record already holds the rewritten input; a hook added only after the pause, that changes the arguments, now refuses the call. A call whose tool no longer exists after an approval (snapshots with an agent fingerprint) now rejects withLOUSHO_RESUME_TOOL_MISSINGinstead of giving the model anot-foundtool result; older snapshots keep the old behaviour. - Cancelled and settled tool calls use the shared tool-error shape (LOU-U14.2): a tool call cancelled before it ran (the run was aborted or steered) and a sub-agent call dropped because the lead run was already pausing for another approval now reach the model as
{ error: 'ToolNotRunError', toolName, message, kind: 'not-run' }with the transcript message flaggedisError. Before, the result was{ error: '<text>' }(and the cancelled one was not flaggedisError); the text is now undermessage. - A
permissionsallow(orask) rule no longer overrides a tool’s ownneedsApprovaldeny (LOU-X8 follow-up, behaviour change): the tool’sneedsApprovalis now called for those calls too, and its'deny'/{ deny }denies the call; anallowstill skips the tool’s ask. Adenyrule still wins without callingneedsApproval. Migration: a tool that should run under anallowrule must not deny it fromneedsApproval. See docs/approvals.md. agent.session()passes the agent’screateAgent({ compaction })to the session (LOU-W8 follow-up), sosession.compact()uses the agent’s strategy and sizes unlessagent.session({ compaction })sets its own.- The
cloudflare-workertarget builds withaiv7 installed (LOU-D28c). Withaiv7 (@ai-sdk/openai/@ai-sdk/anthropicv4),lousho build --target=cloudflare-workerfailed with “Node builtins leaked” (node:module,node:dns,node:diagnostics_channel,node:async_hooks). Those are not imports:aiv7 and@ai-sdk/provider-utilsv5 probe them at run time throughprocess.getBuiltinModule(), only when running on Node, so the bundle never loads them on Workers. The build’s leak check now exempts exactly those four ids, and only as the argument of agetBuiltinModule/loadBuiltinModulecall; any othernode:reference still fails the build. Nonodejs_compatflag is needed. The 16 Worker bundle tests that LOU-D28b skipped onaiv7 now run on both majors. The bundle is about 3.4 MB raw on v7 (1.7 MB on v4), well under the 64 MiB limit. - BREAKING: sandboxed and workspace commands get an allowlisted environment (LOU-X11). Commands run by
NodeWorkspace.exec()andSandboxShellno longer see the host’sprocess.env, soenv/printenvcannot reveal API keys or cloud credentials. They get a small base a shell needs (PATH,HOME/USERPROFILE,TMP/TEMP/TMPDIR,LANG,LC_*,TERM, plusSystemRoot,SystemDrive,ComSpec,PATHEXT,WINDIRon Windows) and what you add. A container (SubprocessSandbox) gets only what you add, never the host env.SandboxShellgainsenvandinheritEnvoptions;NodeWorkspaceOptions.inheritEnvandSandboxShellOptions.inheritEnvaccepttrue;SandboxRunOptionsgainsinheritEnv(NoopSandboxkeeps passing the host env unless it isfalse, whichSandboxShellsets).SubprocessSandboxgainsnetwork: 'none' | 'default' | { allow: string[] }(default'none', as before);{ allow }is validated and kept onsandbox.networkbut runs with no network until the egress proxy of LOU-X12 enforces it (fail closed). Migration: pass what commands need, either values (env: { NODE_ENV: 'test' }) or host names to copy (inheritEnv: ['CI', 'NODE_OPTIONS']); to restore the old behavior, passinheritEnv: true. See docs/workspace-tools.md. - Internal (LOU-D28b): the test suite runs with
aiv7 installed asai. A test helper (src/providers/aiMajor.testkit.ts:installedAiMajor,describeOnAiV4,itOnAiV4) reads the installed major; tests whose behavior is the same on both majors are made major-agnostic, the 66 that only make sense on v4 (v4 message and call shapes,MockLanguageModelV1) or on a Worker bundle that still leaksnode:built-ins on v7 (LOU-D28c) are skipped there. Thetypecheck-ai7CI job now also builds and runsnpx vitest run. Test and CI change only; no product change. - Internal (LOU-D28a): the SDK source now type-checks against
aiv7 (with@ai-sdk/openai/@ai-sdk/anthropicv4) as well asaiv4.AiSdkProviderbuilds itsaiv4 request with SDK-owned structural types instead of the v4-onlyCoreMessage/CoreAssistantMessage/CoreToolMessage/Outputtypes and no longer callsai’stool()(an identity function on v4); the legacy.toolthatdefineTool()/toolDescriptorFromSchema()hand-build is cast once at that boundary. No runtime change on v4. A new CI job runstsc --noEmitwithai@7installed. Theai/@ai-sdk/*peer ranges are unchanged for now (LOU-D28d). - MCP tools without
readOnlyHintnow ask for approval by default (LOU-Z5, BREAKING for MCP users):loadMcpTools(),connectMcp()andcreateAgent({ mcpServers })setneedsApprovalfrom each tool’s MCP annotations:readOnlyHint: trueruns,destructiveHinttrue or absent (the MCP spec default) asks,destructiveHint: falseruns. Choose per server with the newapproval: 'annotations' | 'always' | 'never' | ({ name, annotations }) => boolean(loadMcpTools(client, name, { approval }),connectMcp()server entries,createAgent({ mcpServers })andAgentSpec.mcpServers, which validates it; a spec file takes the three strings). The descriptor keeps the raw annotations on the newToolDescriptor.metadata?.mcp.annotations(typesMcpToolAnnotations,ToolMetadata,McpApproval) and uses the annotationtitleasdisplayName. Migration: passapproval: 'never'to restore the old behaviour (every MCP tool runs without asking).createAgent()agents pause withfinishReason: 'awaiting-approval', while a bareAgentExecutorwithout anapprovalStorenow throws for such tools. See docs/configuration.md#mcp-tool-approval-approval. - Small built-in tools are defined with
defineTool(LOU-D24, not breaking):currentDateTool,dayNameTool,createEmailTool(),createHttpTool(),createSlackTool(),createDelegateTool()and the descriptorsloadMcpTools()builds no longer callai’stool(). They now carry canonicalinputSchema/execute(the legacy.toolstays), and the built-ins have the tool namescurrent_date,day_name,send_email,http_request,slack_alertanddelegate_to_<agent>. Descriptions, schemas, approval and sandbox behaviour and the factory signatures are unchanged. MCP descriptors are built with the new internaltoolDescriptorFromSchema(), which (unlikedefineTool) accepts any server-provided name or empty description.routeFetchThroughSandbox()reads the canonicalexecutefirst. - GitHub and Jira tools are defined with
defineTool(LOU-D25, not breaking): the 28createGitHubTools()tools and the 20createJiraTools()tools no longer callai’stool(), so nothing undersrc/toolsimportsaiany more. They now carry canonicalinputSchema/execute(the legacy.toolstays). Tool names, descriptions, schemas, the sandbox flags and the factory signatures are unchanged. The GitHub out-of-scope tools (branch/file writes, code search, issue CRUD, merge) are disabled through the canonicalexecuteas well astool.execute, so calling one still throws “out of scope” before any HTTP request.JiraTools.register()now also routes tools registered withdefineToolthrough the sandbox seam. - BREAKING (types only):
Message.contentis widened fromstringtostring | ContentPart[](LOU-V11). Messages you build keep working unchanged, but code that readsmessage.contentas a string (.length,.startsWith(), a template literal) no longer type-checks. Migration: readtextOf(message)instead, or narrow withtypeof message.content === 'string'. A customLLMProviderthat forwardscontentshould handle the parts or sendtextOf(message). - The execute context is our own type (LOU-D23, not breaking): the new exported
ToolExecutionContext({ toolCallId, messages: readonly Message[], abortSignal?, sessionId?, onDelegatedUsage? },Messagefrom@lousho/build-ai-agent) replaces theaiSDK’sToolExecutionOptionsas the type of the second argument ofdefineTool()’sexecute,ToolDescriptor.execute,DefinedTool.executeand the third argument ofsandboxExecute, and is whatbuildToolRunContext()returns; the public types no longer importToolExecutionOptionsfromai. A function typed forai’s options that only readstoolCallIdorabortSignalstays assignable.ToolRunContextis now a deprecated alias of the optional fields ofToolExecutionContext(it already existed withonDelegatedUsageandtoolCallId).AgentExecutionOptions.messagesis typedMessage[]instead of theaiSDK’sCoreMessage[](the field is unused by the SDK). The legacyToolDescriptor.tool(aiv4Tool) is unchanged. See docs/tools.md#execute-context. - BREAKING (small):
SDKError.codevalues are now theLOUSHO_*codes (LOU-D2):AGENT_EXECUTION_ERROR->LOUSHO_AGENT_EXECUTION_FAILED,TOOL_EXECUTION_ERROR->LOUSHO_TOOL_EXECUTION_FAILED,LLM_PROVIDER_ERROR->LOUSHO_PROVIDER_REQUEST_FAILED,SESSION_AWAITING_APPROVAL->LOUSHO_SESSION_AWAITING_APPROVAL,FLOW_EXECUTION_ERROR->LOUSHO_FLOW_EXECUTION_FAILED,CONFIGURATION_ERROR->LOUSHO_CONFIG_INVALID,VALIDATION_ERROR->LOUSHO_VALIDATION_FAILED,TIMEOUT_ERROR->LOUSHO_OPERATION_TIMEOUT,RATE_LIMIT_ERROR->LOUSHO_PROVIDER_RATE_LIMITED;codeis no longer optional (new SDKError(message)getsLOUSHO_GENERIC_ERROR). No class was renamed. The messages ofSDKErrors other thanToolExecutionError,LLMProviderError,TimeoutErrorandRateLimitErrorgain a second line with the code, hint and docs link, andloadSpec()now rejects a spec whose top-level field is a likely typo of a spec field (LOUSHO_SPEC_UNKNOWN_FIELD; other unknown fields are still ignored). Migration: comparecodeagainst the new values (or useinstanceof); match messages withtoContain/ a regex, or compareerror.detail, instead of exact equality. - BREAKING (small): tool failures that did not go through the thrown-error path now use the shared shape (LOU-U14), so
erroris an error name instead of a message. Before: an unknown tool or a missing registry gave{ error: "Tool 'x' not found" }/{ error: 'No tool registry available' }; a rejected approval gave{ error: 'Tool execution was rejected by the reviewer', note }; a tool that threw after an approval gave{ error: '<message>' }; a call left without a result on resume gave{ error: 'Tool call was not run: ...' }. Now each is{ error: '<ToolNotFoundError | ToolRejectedError | <thrown error name> | ToolNotRunError>', toolName, message: '<the old text>', kind, ... }. A tool approved but missing from the registry on resume used to rejectresumeAfterApproval()withTool 'x' not found in registry; it now resolves with anot-founderror result. The sandbox guard throwsSandboxRequiredError(same message, still anError). Migration: readmessageinstead oferrorwhen you want the text, and branch onkind.ToolArgumentsValidationError.toToolResult()now also returnskind: 'validation'. - Background sub-agents still running when the lead run ends are now cancelled (LOU-Y4.2) instead of running to completion on their own; a lead run that pauses for approval cancels them too (the resumed run starts with none). Migration: to keep them, have the lead collect them with
agent_awaitbefore its final answer, or setsubagentOptions: { awaitBackgroundOnFinish: true }. - Heavy dependencies are optional peers (LOU-D40):
dockerode(Docker sandboxing,SubprocessSandbox),@modelcontextprotocol/sdk(serveMcp,lousho mcp, MCP clients) andprompts(the interactive questions oflousho init) moved fromdependenciestopeerDependencies, marked optional inpeerDependenciesMeta. They were already loaded lazily; now a project that uses none of them does not install them. Using one without its package fails that call withMissingPeerDependencyError, which now also carriesfeatureand names the exact command (for examplenpm install dockerode@^5.0.1).lousho doctorlists the three with what they enable (a missingdockerodefails the check when the agent spec uses a sandboxed tool), and its version lookup no longer returnsundefinedfor packages whoseexportsmap hidespackage.json. Migration: install the peer if you use Docker sandboxing (npm install dockerode), MCP (npm install @modelcontextprotocol/sdk), or interactivelousho init(npm install prompts;npm create lousho-agentalready does, andlousho init --yesneeds nothing). See docs/installation.md. - The
httptool no longer loadsundicifor default requests (LOU-D40): with TLS verification on (the default) it uses the runtime’s globalfetch;undici(still a regular dependency) is loaded only forvalidateSSL: false, whose per-request TLS dispatcher the globalfetchcannot express.yamlstays a dependency (agent specs and skills need a YAML parser). - Retries no longer stack (LOU-V7.2): the built-in providers (
OpenAIProvider,AnthropicProvider,OllamaProvider,OpenRouterProvider) now passmaxRetriesto theaiSDK calls:config.maxRetries, or 2 (theaiSDK’s default, so behaviour is unchanged) when unset.createAgent()builds the providers it resolves from strings withmaxRetries: 0and retries in itswithRetry()wrapper instead, so retries happen in one place (at most 3 calls by default, as before) and each one is observable. If you wrap a built-in provider inwithRetry()/resilientProvider()yourself, build it withmaxRetries: 0; note that amaxRetriesyou already set in a provider config is now honoured by theaiSDK. AgentTypeis optional and deprecated (LOU-D34, not breaking):AgentBuilder.build()no longer requiressetType()andAgentConfig.agentTypeis now optional (createAgent()andspecToAgent()agents carry no type).AgentType,setType()and theagent-typesregistry/validators are marked@deprecated: they have no runtime effect and will be removed in the next minor; they stay exported for now. Drop yoursetType(...)calls.- BREAKING:
encrypt()now generates a random salt per call instead of a hardcoded one. Ciphertext produced before this change cannot be decrypted with the new code and must be re-encrypted. - BREAKING: The mock repositories (
MockAgentRepository,MockSessionRepository,MockResultRepository,MockMemoryRepository,MockAttachmentRepository— previously re-exported fromsrc/data/mocks.tsvia the package root/./datasubpath; this package has nocreateMockRepositoriesfactory) are no longer exported from the package root. Import them from@lousho/build-ai-agent/testinginstead. - One session-API client behind
remoteAgent()andremoteTarget()(LOU-D53): both now share an internal client (src/server/sessionClient.ts) forPOST <url>/chat, the SSE read, bearer-token scrubbing and the error mapping, and use one pair of codes,LOUSHO_REMOTE_REQUEST_FAILEDandLOUSHO_REMOTE_UNAUTHORIZED. The unreleasedLOUSHO_REMOTE_AGENT_FAILED(LOU-Y7) is removed: aremoteAgent()failure now carriesLOUSHO_REMOTE_REQUEST_FAILED(network, non-2xx, truncated stream, or a remote run that ends in an error, with “the remote run ended in an error” in the message) orLOUSHO_REMOTE_UNAUTHORIZED(401). No option or behavior of either function changed.
[1.0.0-alpha.8] - 2025-10-05
Added
- ✅ Complete Phase 5 migration: Security, Storage, Templates, and Utils modules
- ✅ Security module with encryption, hashing, and quota validation
- ✅ Storage service with file locking mechanism
- ✅ Template rendering engine with Jinja2-like syntax
- ✅ Comprehensive utility functions (errors, formatters, validators)
- ✅ File extraction and processing utilities
- ✅ JSON path navigation utilities
- ✅ Framework-agnostic architecture (zero framework dependencies)