Skip to main content

[Unreleased] - 2026-09-28

Published

  • @lousho/build-ai-agent and create-lousho-agent are 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 failed lousho init install 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), the lousho CLI (was loushy), create-lousho-agent (was create-loushy-agent), the exports useLoushoAgent, loushoAgent, LoushoAgentSource, LoushoUIMessageChunk and the other Lousho* types, every LOUSHO_* error code and environment variable (LOUSHO_MODEL, LOUSHO_API_TOKEN, LOUSHO_STORE, …), the .lousho/ directory, the lousho.* span attributes and the data-lousho-approval stream part. Migration for a checkout that used the old name: replace loushy with lousho (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 with LOUSHO_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 and subagentPath: [<remote agent>], and a remote ask_question arrives as a question. Deciding it on the lead (resolve, streamResolve, answer) posts the decision to the remote POST <url>/chat/:sessionId/approvals/:id and the continuation’s final answer is the task result; a further pause pauses the lead again. The lead’s approval snapshot stores the remote session id, the remote approval id, the taskId and 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 the task call’s structured tool error with LOUSHO_REMOTE_UNAUTHORIZED / LOUSHO_REMOTE_REQUEST_FAILED. A remote agent with an output schema now returns its object as JSON with the footer (the V4.2 shape), read from run.done’s object. RemoteSubagent.run() takes pausable and decision (type RemoteRunOptions); the internal session client gains resolveRemoteApproval() and SessionTurnSummary.object. Only a lead run without an approval store keeps the old LOUSHO_SESSION_AWAITING_APPROVAL error. See docs/sub-agents.md#remote-approvals.
  • Structured output for sessions and sub-agents, typed for either zod major (LOU-V4.2): createAgent({ output }) and ExecuteOptions.output accept a zod 3 schema, a zod 4 schema (zod/v4 on zod 3.25, or zod 4) or a Standard Schema that can produce JSON Schema, and result.object is inferred from each without a cast (InferSchemaOutput, as defineTool). AgentSession is generic (AgentSession<TObject = unknown>): agent.session().send() and .stream() results carry the typed object. A sub-agent with its own output returns its validated object as JSON (then the taskId footer) as the task and agent_await result; its output-invalid finish is a structured tool error. Sub-agents do not inherit the lead’s output; remoteAgent() returns text only. The exported spec schemas (agentSpecSchema, …) are typed as SpecSchema<T> / SpecObjectSchema<T>, so the published schema-*.d.ts no longer depends on zod 3 generics (skipLibCheck: false projects on zod 4). The Ollama missing-peer note now says ollama-ai-provider-v2 needs 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 and create-lousho-agent, checks the tarball (no .env, tests or secret-looking strings; entry count and size caps), runs npm publish --dry-run for both (nothing is published), installs the tarballs plus peers from the registry into a fresh project and verifies ESM and CJS loads of every exports entry, a mock-model agent turn, the lousho bin (--help, doctor) and tsc with moduleResolution bundler and node16.
  • One event system (LOU-D41): AgentEvent listeners for callers who do not iterate a run. createAgent({ onEvent: (event: AgentEvent) => void }) and the new ExecuteOptions.onAgentEvent (also taken by AgentExecutor.stream() and resumeAfterApproval()) are called synchronously with every AgentEvent of the run, on send() / execute() as on stream(), 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 one text.delta per step). A run with a listener also reports hook events, permission decisions, budgets and guardrails to it when it is not streamed. A non-streamed resumeAfterApproval() 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, ExecutionEvent and ExecutionEventType (LOU-D41). onEvent keeps working: the executor now emits only AgentEvents, 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 with subagent), with a one-time console.warn. Differences: finish now 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, onRunEnd throwing) now gets an error event too. Migration: replace onEvent with onAgentEvent (or createAgent({ onEvent })); start -> run.start, text-complete -> text.done (step usage on step.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. Use AgentExecutor.stream() / agent.stream() to stream a run. Passing it in an object literal is now a type error; remove the property.
  • ToolDescriptor.injectStreamingController and defineTool({ 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 (AgentExecutionOptions is a legacy type no SDK API takes). Remove the property; stream a run with agent.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 KVStore note in the deployment guide now match the code. Ticket ids are gone from user-facing prose.
  • lousho --help, -h and help print 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 --help and the message after a failed lousho init install 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-sdk is documented as not working. Stale “planned / not yet” statements about agent-directory channels, toolCallId in sandboxed tools and the Agent Forge Settings tab are corrected.

Changed

  • No any left in the SDK’s types (LOU-D16): npm run lint now fails on any ESLint warning, and no-explicit-any, no-unused-vars and ban-ts-comment are errors. Runtime behavior is unchanged; some exported types are stricter, which can be a compile error where code read an any value 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.output and .variables, FlowExecutionContext.variables / session / memory, GenerateResult.rawResponse, StreamChunk.toolResult.result, extra keys of LLMProviderConfig, AgentConfig.expectedResult / events / metadata, AgentExecutionResult.result, ResultData.result, SessionData.data, JiraTicket.customFields, the extra keys of formatZodError()’s result, ToolConfiguration.options, ToolSetting.options, FlowToolSetting.options, ToolNode.toolOptions, UIComponentNode.componentProps, FlowChunkEvent.issues / input / toolResults[].args / componentProps, and ToolDefinition.function.parameters.
    • FlowExecutionEvent is a union discriminated on type, with data typed per event type (new FlowExecutionEventOf<T> and FlowExecutionEventDataMap): check event.type before reading event.data (if (event.type === 'tool-call') event.data?.tool).
    • AgentConfig.settings (and AgentBuilder.setSettings()) is the new AgentSettings ({ model?: string; [key: string]: unknown }).
    • StorageService.readPlainJSONAttachment<T>() (and IStorageService’s) defaults T to unknown: pass the type you stored, readPlainJSONAttachment<MyRecord>(key).
    • OpenRouterProvider.getModelInfo() returns the new OpenRouterModel ({ id: string; [key: string]: unknown }), null or undefined.
    • FlowBuilder.addAgent() / setAgents() take FlowAgentDefinition; injectVariables() and applyInputTransformation() take a FlowDefinitionNode (injectVariables() returns the type it was given); validateFlowInput() takes Record<string, unknown>; validateAgentTools() takes Record<string, { tool?: unknown }>; createDynamicZodSchemaForInputs() returns z.ZodObject<Record<string, z.ZodTypeAny>>.
    • Still accepted as before: needsApproval predicates typed for a tool’s own arguments, any argument to formatAxiosError() and hasRequiredKeys(), and anything as writePlainJSONAttachment() data. The deprecated ExecutionEvent keeps toolResult.result: any.
  • Eval cassettes hook in at the model boundary (LOU-D46.2): lousho eval --record / --replay / --drift no longer reassigns AgentExecutor.execute at runtime. The run loop routes every model call through a small provider-interception seam (setProviderInterceptor() / interceptProvider() in src/providers/interception.ts; a no-op unless an interceptor is installed), and the eval runner installs one that answers with the per-case recordReplay() wrapper. Cassette files, keying, --drift output 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 by src/execution (historyLimit, toolConcurrency, a second iteration of an AgentRun), the tool registry, the built-in tools, serveMcp(), NodeWorkspace and the lousho init / studio / chat helpers are now SDKErrors with an existing code (LOUSHO_CONFIG_INVALID via ConfigurationError, LOUSHO_RUN_ALREADY_ITERATED, LOUSHO_APPROVAL_NOT_FOUND, LOUSHO_PROVIDER_UNKNOWN, LOUSHO_TOOL_EXECUTION_FAILED). Messages are unchanged; error.code, error.hint and 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 matched error.constructor === Error or error.name === 'Error' needs error 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 the llm registry now throw an SDKError (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 (what ToolArgumentsValidationError carries 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_INVALID and LOUSHO_CHANNEL_REQUEST_FAILED, all in docs/errors.md. Code that matched these errors by error.constructor === Error or error.name === 'Error' must check instanceof SDKError / error.code instead. ToolExecutionError takes an optional fourth code argument. A new test (src/utils/plainErrors.test.ts) fails when new SDK code throws a plain Error; the 5 that remain are listed there with the reason.
  • The last two allowlisted plain Errors are SDKErrors (LOU-D16): OpenRouter’s model-catalog fetch (LOUSHO_PROVIDER_REQUEST_FAILED; getModels() / getModelInfo() still catch it and fall back) and the UI runner’s failed POST to a remote agent (LOUSHO_REMOTE_REQUEST_FAILED, as used by useLoushoAgent() 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 with name: 'SDKError' instead of 'Error'. SDKError itself moved to src/utils/sdkError.ts (re-exported from the same places as before) so the browser entries can throw it without importing ai. The allowlist in src/utils/plainErrors.test.ts is down to the 3 intended sites.

Added

  • Reasoning (LOU-V13): new reasoning option on createAgent(), send() / stream() (per call, overriding the agent’s), ExecuteOptions and GenerateOptions: 'none' | 'minimal' | 'low' | 'medium' | 'high' or { effort?, budgetTokens?, summary?: 'auto' | 'none', force? } (types ReasoningOption, ReasoningEffort, ReasoningSettings). The built-in providers send it through providerOptions on ai 4.3 and 6/7: OpenAI reasoningEffort (+ reasoningSummary), Anthropic thinking: { type: 'enabled', budgetTokens } (effort table 1024 / 2048 / 8192 / 24576 tokens), Ollama think (ollama-ai-provider-v2 only; ignored with one warning on ai 4), and OpenRouter’s unified reasoning body field (effort or max_tokens). It is only sent to model families known to accept it (force: true overrides); 'none' sends nothing. New events reasoning.start, reasoning.delta (text) and reasoning.done (text, tokens?) in the AgentEvent union, emitted inside the step before its first text or tool call, from ai v4 reasoning parts and v6/v7 reasoning-start/delta/end; ExecutionResult.reasoning holds the run’s reasoning text and GenerateResult.reasoning a call’s blocks (new type ReasoningBlock); new stream chunk types reasoning-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 new Message.reasoning, which the Anthropic provider sends back unmodified (it survives checkpoints and resumes). The UI reducer keeps UIMessage.reasoning, toUIMessageStream() emits reasoning-start / reasoning-delta / reasoning-end, lousho acp sends agent_thought_chunk, lousho chat prints reasoning dimmed, and chat spans carry lousho.usage.reasoning_tokens. Cassettes record the reasoning chunks. See docs/reasoning.md.
  • Tools know their session (LOU-D23.2): ToolExecutionContext.sessionId is now set whenever the run has one: AgentExecutor.execute({ sessionId }), send({ sessionId }), every agent.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 the invoke_agent span’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, on mountChannels() and on defineChannel() receives failures after the acknowledgment (reply delivery, a failed turn, an approval continuation; default console.error with channel, stage, session and SDK error code, never a token) and a failed turn tells the user so; mountChannels({ onDecision }) reports who decided; parse gets a third ctx argument (ChannelContext: pending approval, session id, hasSession) and a { decision } may carry inbound and approver. 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, scope im:history) are sessions keyed on the DM channel. See docs/channels.md.
  • Resumable sub-agent tasks (LOU-Y6): every task result ends with a taskId (the footer is now [sub-agent '<name>': N step(s), finish reason '<reason>', taskId 'task_1']; a remote one gains , taskId '...' too), and task takes optional taskId and mode: 'new' | 'resume' | 'fork' (default new without a taskId, resume with one). resume continues that sub-agent with its transcript and the new prompt as the next user turn; fork starts a new task (new taskId) from a copy of it and leaves the original alone. Conversations are kept per lead session in the agent’s store.sessions (new SubagentOptions.sessions, set by createAgent({ store })) for lead runs with a sessionId (checkpointed send() 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 now task_<n> counted with the synchronous ones): a finished one can be resumed, a running one is refused with the new LOUSHO_SUBAGENT_TASK_BUSY; an unknown, foreign or other sub-agent’s id gets the new LOUSHO_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 by taskId continue in the same remote session (LOU-Y7.2; RemoteSubagent.run() takes sessionId and taskId); 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-facing task description 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|docker leaves every optional peer of the SDK (read from its peerDependenciesMeta) out of the bundle, replacing the hand-kept dockerode / ollama-ai-provider-v2 list, 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 type AgentFingerprint: 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 option onAgentDrift: 'warn' | 'error' | 'ignore' (type AgentDriftMode) on createAgent(), AgentExecutor.execute() and resumeAfterApproval(): 'warn' (default) emits the new agent.drift event (AgentDriftEvent: model?, toolsAdded, toolsRemoved, toolsChanged, instructions) and a console.warn, 'error' rejects with the new LOUSHO_AGENT_DRIFT and leaves the checkpoint (or the pending approval) untouched. A pending tool call whose tool no longer exists always rejects with the new LOUSHO_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 take currentAgent (set by createAgent()) 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, by networkName) an internal Docker network (Internal: true, label com.lousho.sandbox=egress), has the broker listen on its gateway address for that subnet only, and starts each container on it with HTTP_PROXY/HTTPS_PROXY pointing there, so the broker (allowlist: its rule hosts plus allow) is the container’s only route out. New sandbox.close() stops the listener and removes the network it created. New broker.listen({ host, clients, allow? }) adds a listener on another address that drops peers outside the clients subnet; 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 code LOUSHO_SANDBOX_EGRESS_UNSUPPORTED (no container is started). Without a broker, { allow } still means no network. See docs/workspace-tools.md.
  • The Docker sandbox honors cancellation (LOU-U23): SubprocessSandbox.run() takes signal (SandboxRunOptions). When it aborts, the container is killed and force-removed (errors from an already stopped or removed container are ignored) and run() rejects with an AbortError, like NoopSandbox; an already-aborted signal starts no container; a timeout uses the same cleanup. SandboxShell passes the tool’s abort signal down, so a cancelled shell call stops its container (it used to run until the timeout) and reports aborted: true. See docs/workspace-tools.md.
  • Schedules on Cloudflare Workers (LOU-P9): the cloudflare-worker target writes the spec’s { type: 'cron', cron, input, name?, timezone? } triggers (deduplicated) to wrangler.toml as [triggers] crons = [...], and the generated Worker exports scheduled(controller, env, ctx), which runs every trigger whose expression equals controller.cron as an agent turn inside ctx.waitUntil(), in session schedule:<name> (visible in the KV store when bound). A failing trigger is logged with console.error (name and error code) and never stops the others. The build fails with LOUSHO_SCHEDULE_INVALID naming the trigger for what Cloudflare would not fire: a timezone (crons are UTC), a seconds field or @daily shortcut, a numeric day-of-week (Cloudflare counts 1 as Sunday). New handleScheduled(agent, schedules, controller, ctx) (root and deploy-runtime-worker) wires defineSchedule schedules to a hand-written Worker’s scheduled(). startSchedules shares its fire step with it. See docs/schedules.md#on-cloudflare-workers.
  • ai v6 and v7 as peers (LOU-D28d): peerDependencies now accept ai ^4.3.19 || ^6.0.0 || ^7.0.0 and @ai-sdk/openai / @ai-sdk/anthropic ^0.0.42 || ^1.0.0 || ^3.0.0 || ^4.0.0 (still optional), plus the new optional peer ollama-ai-provider-v2 ^2.0.0 || ^3.0.0 || ^4.0.0, so a project on the current ai major installs without peer conflicts. Pairings: ai 4 with @ai-sdk/* 0.0.x/1.x and ollama-ai-provider 1.x; ai 6 with @ai-sdk/* 3.x and ollama-ai-provider-v2 2.x/3.x; ai 7 with @ai-sdk/* 4.x and ollama-ai-provider-v2 4.x. A missing provider package’s MissingPeerDependencyError.installCommand names the version for the installed ai major (npm install @ai-sdk/openai@^4.0.0 on ai 7; unchanged on ai 4). OllamaProvider loads ollama-ai-provider-v2 on ai 6/7; that package needs zod 4, which the SDK does not support yet, so its install hint says so and Ollama stays on ai 4 for now. lousho doctor checks provider packages against the installed ai major and flags a mismatched pair (e.g. ai 7 with @ai-sdk/openai 1.x) with the version to install; a missing or unsupported ai gets npm install ai@^7.0.0. lousho init / npm create lousho-agent scaffold ai@^7.0.0 with @ai-sdk/openai@^4.0.0 / @ai-sdk/anthropic@^4.0.0 for OpenAI and Anthropic, and keep ai@^4.3.19 for Ollama (zod 4) and OpenRouter (@ai-sdk/openai 2+ defaults to the Responses API, not yet verified there). The hint of resolveProvider()’s rare synchronous missing-peer error lists the command for each ai major. 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 --list prints the index. The registry comes from --registry or LOUSHO_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 (--yes skips it; a non-interactive stdin without --yes refuses; --dry-run writes 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 only http(s) and local paths are read; nothing from a registry is executed and dependencies are only printed as an npm install line. New error codes LOUSHO_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 KVCheckpointStore and Agent Forge’s file store (LOU-D43.2): KVCheckpointStore (and KVStore, with the new historyLimit option) keeps the bounded per-session history memoryStore() keeps, as one index key (<prefix><sessionId>#history) and one key per entry (<prefix><sessionId>#history/<id>) next to the unchanged latest-checkpoint key, so agent.fork(), compareTrajectories() and time travel work for Worker-deployed agents; a store written before this change still loads. A save now makes one get and two puts more (historyLimit: 0 turns the history off), and two concurrent saves of one session can lose an index update (KV has no transactions). Agent Forge’s FileCheckpointStore reads a partially written history file as empty and writes its files atomically. The history contract suite now runs against KVCheckpointStore too. See docs/durable-execution.md#checkpoint-history.
  • Hook outcomes (LOU-X3): a preToolCall hook may return { deny: reason } (the call does not run; the model gets the same kind: 'denied' tool error as a needsApproval deny, streams see tool.error, and onPermissionDecision / permission.decision record decision: 'deny' with the new hook field and reason), { result: value } (the call does not run; value is its result) or { input: args } (the call runs with args, validated against the tool’s schema again; a mismatch is a kind: 'validation' tool error naming the hook). A postToolCall hook 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 marked replacedByHook on the tool.done event, the ToolCallOutcome / tool-result event and the transcript’s tool message metadata. Hooks still run first (after argument validation, before permission rules, tool guardrails and needsApproval), 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 a PreToolCallDecision and runPostToolCall() to the name of the replacing hook (new types PreToolCallOutcome, 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’s console.log goes to stderr). It implements initialize, session/new, session/prompt (text and resource_link blocks) and session/cancel, streams session/updates (agent_message_chunk, tool_call, tool_call_update), asks session/request_permission (Allow / Reject) when a tool needs approval and streams the continuation, and maps finish reasons to stop reasons (max-steps to max_turn_requests, budget-exceeded / length to max_tokens, guardrail to refusal, a cancel to cancelled). One ACP session is one SDK session. An ask_question pause ends the turn with the question as the agent’s message; the next prompt answers it. A failed run answers a JSON-RPC error whose data.code is the SDK error code. The protocol core is exported as serveAcp(agent, { input, write, store? }) (new type ServeAcpOptions), transport-independent. Not supported yet: session/load, the client’s fs/* and terminal/* methods, images, audio and embedded resources, plan and 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 (and docker; the path may also be --agent) builds a directory into a server whose entry calls resolveAgentDir() and createDeployedServer(agent, { schedules, channels }), so the deployed process starts the schedules/ and mounts the channels/ under /channels and logs which it found. The code files are pre-bundled by the existing tsup step into dist/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-schedules starts none. The deploy runtime also exports createAgent, resolveAgentDir and storeFromEnv. 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 under basePath (default /api/agent; trailing slash accepted, other paths 404) from a Next.js App Router app/api/agent/[[...path]]/route.ts, SvelteKit +server.ts, Hono or Bun.serve, with no framework or node:* import. auth is a bearer token or a (request) => boolean | Promise<boolean> (default none: a public route needs one; GET /health stays open). useLoushoAgent({ url, approvalsUrl }) works against it (POST <basePath> with { input, sessionId? }, POST <basePath>/approvals/:id), and uiMessageStream: true adds POST <basePath>/ui for useChat. hasBearerToken in src/server/fetchRoutes.ts is 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, finish with the AI SDK finish reason), and toUIMessageStreamResponse(run, init?) returns the SSE Response (data: [DONE], header x-vercel-ai-ui-message-stream: v1) for a route handler, so useChat can render a Lousho run. An approval pause or ask_question is a data-lousho-approval part (approvalId, toolCallId, toolName, input, kind?, question?); usage and the run’s own finish reason are in the finish chunk’s messageMetadata. fromUIMessages(messages, { lastUserOnly? }) converts the UIMessage[] useChat posts (text, image and file parts; others ignored) to an AgentInput. Own structural chunk types (LoushoUIMessageChunk, UIMessageLike, …), no ai import and no node:*, 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 needsApproval function may return (or resolve to) 'approve', 'deny', 'ask' or { deny: reason } besides a boolean (true is 'ask', false is 'approve'; new type ApprovalOutcome), and gets a second argument { toolName, toolCallId, sessionId?, messages } (new type ApprovalCheckContext). A deny does not pause: the call is not run, the model gets a kind: 'denied' tool error with the reason, streams see tool.error, and onPermissionDecision / permission.decision record decision: 'deny' with the new reason field (no rule); the audit entry is now reported once the call is decided (after tool guardrails) instead of before them. New helpers always(), never() and once({ per?: 'tool' | 'args' }) (with type ApprovalPolicy): 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’s tool message (metadata.approval, set by resumeAfterApproval() and its streamed form), so it persists with sessions, checkpoints and approval snapshots and survives a resume in another process. A permissions deny rule wins; an allow or ask rule 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 asserting needsApproval was 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 from agent.approvals.streamResolve() / streamAnswer() instead of replaying it after the turn ends; createAgentRunner’s approve() / reject() / answer() (React, Vue, Svelte) read that SSE stream event by event (an ApprovalOutcome JSON from an older server still works), and lousho chat prints the continuation as it streams. The UI reducer maps a streamed tool.error named ToolRejectedError to a rejected tool call. A second pause arrives as approval.requested. The approvals route has always answered with SSE, so no client opt-in was added.
  • session.compact() and session.clear() (LOU-W8): await session.compact({ strategy?, protectedTokens?, contextWindow?, signal? }) compacts the session’s transcript now, whatever its size, with the given strategy, else agent.session({ compaction }) (new option, the value of createAgent({ 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 emits context.cleared (new ContextClearedEvent in the AgentEvent union) and rejects with LOUSHO_SESSION_AWAITING_APPROVAL when a durable turn waits on an approval; it keeps the session id. New session.on(listener) receives compaction.start / compaction.done (which gain an optional trigger: 'manual') and context.cleared. Both reject with the new LOUSHO_SESSION_BUSY while a turn is in flight; compact() also with LOUSHO_SESSION_TURN_PENDING for an interrupted durable turn. lousho chat gains /compact and /clear. See docs/compaction.md#compacting-a-session.
  • Discord channel (LOU-P6): discordChannel({ publicKey, applicationId, botToken?, name?, fetch? }) serves Discord’s HTTP Interactions endpoint through mountChannels() (Web Crypto and fetch only, Worker-safe, no gateway, no new dependency). It verifies X-Signature-Ed25519 / X-Signature-Timestamp (Ed25519 over timestamp + body, 401 otherwise) and answers PING with PONG. 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; an ask_question is answered by the next /ask in the channel. See docs/channels.md#discord.
  • Streaming resume after an approval (LOU-V14): streamResumeAfterApproval(decision, approvalStore, toolRegistry, provider, options?, checkpointStore?) takes resumeAfterApproval()’s arguments and returns an AgentRun whose result is what resumeAfterApproval() returns. Its events are run.start, the decided call’s tool.start / tool.done (tool.error for a rejection), then the continuation’s events as in a fresh stream() run; a further pause ends it with approval.requested. Abort, enqueue() and steer() work as on any run, and the approval can be resolved in another process. createAgent() agents get agent.approvals.streamResolve(decision, { signal }) and streamAnswer({ id, answer }, { signal }) (a pause made in a session continues in it, run.done after the transcript is saved; a dynamic agent keeps the model the paused run used). Also new: the ResumeRequest type and resumeRequest(request) (resumeAfterApproval() with one object argument). Additive: resolve(), answer() and resumeAfterApproval() 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 ephemeral 127.0.0.1 port by default) and resolves to { url, env, baseUrl(host), close() }. rules map 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: pass broker.env (HTTP_PROXY/HTTPS_PROXY/NO_PROXY, both cases) to NodeWorkspace or SandboxShell’s env. Hosts outside the rules and allow get a 403 without a connection; hosts resolving to loopback, link-local or private addresses are refused unless in allowPrivate; a client Authorization header for a brokered host is replaced; hop-by-hop headers are dropped. Injection covers plain-HTTP requests and the /__broker/<host>/<path> form (forwarded to https://<host>/<path>, returned by baseUrl(host)); HTTPS CONNECT tunnels are allowlist-checked and passed through untouched (no TLS interception). SubprocessSandbox’s network: { allow } still fails closed: routing containers through the broker is LOU-X12.2. New types CredentialBroker, CredentialBrokerOptions, BrokerHeaderValue. See docs/workspace-tools.md#credential-broker.
  • Memory in agent directories (LOU-W6.3): memory/*.ts|js|mjs files default-export a memory slot (defineMemory(), or the same options without a name; name = file name unless set). Unlike schedules and channels they are part of the agent: loadAgentDir() passes them to createAgent({ memory }), resolveAgentDir() lists them in manifest.memory, and a memory override is merged by name (the override wins a clash). A bad file fails with the new LOUSHO_MEMORY_INVALID naming the file. A directory without memory/ is unchanged. See docs/agent-directories.md#memory.
  • Channels in agent directories (LOU-P7.2): channels/*.ts|js|mjs files default-export a channel (defineChannel() or a built-in factory; name = file name unless set); resolveAgentDir() returns them as channels and manifest.channels, and a file that exports no channel fails with the new LOUSHO_CHANNEL_INVALID naming the file. createDeployedServer(agent, { channels }) mounts them under /channels next to the chat routes. loadSchedules() and loadChannels() share one loader (schedule files that do not export a schedule now throw an SDKError with LOUSHO_SCHEDULE_INVALID, same message). lousho dev and the Cloudflare Worker target do not mount them yet. See docs/agent-directories.md#channels.
  • serveMcp annotations (LOU-Z5.2): defineTool({ annotations }) (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title; stored as metadata.mcp.annotations) is sent in tools/list verbatim, and a tool with needsApproval is advertised readOnlyHint: 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 for needsApproval ones and keeps asking for unannotated ones. The built-in read_file, list_dir, glob, grep, todo_read, current_date and day_name tools are marked read-only. See docs/configuration.md#annotations.
  • Svelte store (LOU-P3): new @lousho/build-ai-agent/svelte subpath with loushoAgent(source, options?). It returns a Svelte store (subscribe(run) => unsubscribe, so $agent works 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), plus send(), stop(), approve(), reject(), answer() and reset(). The run in flight is aborted by stop() and when the last subscriber unsubscribes. The store contract is implemented by hand: the entry imports nothing from svelte, so there is no new peer dependency. See docs/svelte.md.
  • Per-run dynamic config (LOU-V15): createAgent()’s model, instructions / prompt and tools each also take a function of the run, (ctx) => value | Promise<value> (new types PerRun<T> and RunConfigContext = { 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 as input. session.send() / session.stream() gain the metadata option (send() / stream() already had it, and now pass it to these functions too). A function that throws fails the run with the new LOUSHO_CONFIG_RESOLVER_FAILED (field names the option, cause is the error); a session keeps its transcript. A run paused for approval stores its ctx and resolved model in the approval snapshot (agent.metadata.loushoRunConfig): resuming it uses that model and re-resolves the tools with the same ctx; a crash resume from a checkpoint resolves with input: []. Static options behave and type as before. SessionRunner / SessionStreamRunner take an optional fourth call argument (new type SessionTurnCall). See docs/api-overview.md#dynamic-config.
  • Vue composable (LOU-P2): new @lousho/build-ai-agent/vue subpath with useLoushoAgent(source, options?) for Vue 3. It takes the same sources and options as the React hook (either may be a ref, read when a turn starts) and returns messages, status, pendingApproval, error, usage and lastEvent as read-only refs, plus send(), stop(), approve(), reject(), answer() and a new reset() (aborts, forgets the in-process session, clears the chat). The run in flight is aborted by stop() 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 from src/react/ to a framework-neutral src/ui/, together with the run logic extracted from the React hook (createAgentRunner()); the ./react exports are unchanged, and the reducer gains an additive ui.reset action. See docs/vue.md.
  • One flag parser for the lousho CLI (LOU-U21): every command (dev, chat, eval, build, mcp, doctor, studio; init already did) parses its flags with node:util parseArgs in strict mode through src/cli/args.ts. --flag value and --flag=value work everywhere, -- ends the flags, -h / --help prints the usage and exits 0, and an unknown flag, a flag without its value or an extra argument fails with LOUSHO_CONFIG_INVALID and the command’s usage as the hint. Behaviour change (migration): dev, studio, mcp, build and doctor used to ignore unknown flags, and a flag missing its value could take the next flag or a prefix match (--portx 1 read 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 -x is a missing value); --port must be an integer from 0 to 65535 for dev, studio and mcp; dev, mcp and doctor reject a second path; studio --prod --dev is rejected instead of preferring --prod. lousho dev and lousho studio parsing moved from bin/lousho.js into src/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) keeps defineMemory slots in the agent’s SqliteStore file: a new memory_items table (migration 3, one JSON array per scope key, with the same serialization, maxItems and query rules as fileMemory). 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 types SlackChannelOptions, SlackChannelEvent, SlackThread) serves a Slack app’s Events API and interactivity requests through mountChannels(). It verifies the v0 request signature with Web Crypto (no node:* import; the timestamp and header checks are shared with verifySlackSignature()), answers the url_verification challenge, and acknowledges every request before the turn runs. Each Slack thread is one session (team + channel + thread root ts): 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 with chat.postMessage over fetch; a tool approval is posted as Approve / Deny buttons whose click resumes the session, and an ask_question as text answered by the next thread message. The channel contract gains two additive hooks for this: parse(req, respond) may call respond to answer the request before the turn runs, and may return { decision } (new type ChannelDecision; ChannelApprovalDecision now lives in defineChannel.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 types RemoteAgentOptions and RemoteSubagent) makes a deployed agent usable wherever a local sub-agent is: in createAgent({ subagents }) records and SubagentCatalog.resolve(), delegated to by the task tool and by background: true tasks. Each task opens a fresh session on the remote agent over POST <url>/chat (bearer auth: 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 code LOUSHO_REMOTE_AGENT_FAILED, and the token never appears in errors or events; a remote run that pauses for approval fails with LOUSHO_SESSION_AWAITING_APPROVAL naming the remote session (proxying approvals is a follow-up). Worker-safe (fetch only). See docs/sub-agents.md#remote-sub-agents.
  • Eval against a deployed agent (LOU-D47): lousho eval --url <baseUrl> [--token <bearer>] (token also from LOUSHO_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 over POST /chat { sessionId, input }, the SSE stream read to run.done and turned into the usual result (tool calls, reply, finish reason, steps, usage), so checks, scorers, t.judge(), the summary and --junit work unchanged. A check that needs data the stream lacks (maxTokens without usage) fails and names what is missing. Programmatic equivalent: defineEval({ target: remoteTarget({ url, auth?, fetch? }) }) (new remoteTarget(), EvalTarget; agent is now optional when a target is given, and AgentSource also accepts any { send() }). --url with --record / --replay / --drift is rejected with LOUSHO_CONFIG_CONFLICTING_OPTIONS; an unreachable deployment, a non-2xx answer or a truncated stream fails the case with the new LOUSHO_REMOTE_REQUEST_FAILED, and 401 with LOUSHO_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 (a prompt sent to the agent as a new turn, or run({ agent, firedAt, name })); an invalid cron expression, or not exactly one of prompt/run, throws the new LOUSHO_SCHEDULE_INVALID at definition time. An agent directory’s schedules/*.ts|js|mjs files default-export one each (name = file name unless set); resolveAgentDir() returns them as schedules and manifest.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 returns stop(); 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: verify authenticates a request (false or { ok: false } answers 401), parse maps it to { sessionKey, input, metadata?, replyTo } (or null to acknowledge it without a turn), reply delivers the agent’s reply (with stream: true, also partial text as the model writes) and onApproval renders a pause (default: a text prompt through reply). mountChannels(agent, channels, { store, basePath }) returns a framework-free (req, res) handler, in the style of the /chat routes, that serves POST <basePath>/<name> (verify -> parse -> a turn in session `${name}:${sessionKey}` -> reply; turns of one session run one at a time) and POST <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) and webhookChannel({ 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-worker target 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/:id and GET /health; the deprecated POST /chat { message, sessionId? } still works and still checkpoints under sessionId. LOUSHO_API_TOKEN (a Worker secret: wrangler secret put LOUSHO_API_TOKEN) makes every route except /health require Authorization: Bearer <token> (constant-time compare). New KVStore(kvBinding, { prefix?, ttl? }) (src/deploy/kvStore.ts) is an AgentStore on a Workers KV namespace: sessions/<id> (transcript JSON, bytes as { "$bytes" }), checkpoints/<id> (KVCheckpointStore, which gains an optional TTL) and approvals/<id>, with optional per-kind TTLs; the Worker builds it from the existing AGENT_CHECKPOINTS binding, 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: Request in, Response and a ReadableStream SSE body out, bearer auth with Web Crypto); src/server/chatRoutes.ts adapts them to node:http for lousho dev and the node server, and src/server/bearerAuth.ts is gone. The built Worker runs a createAgent() agent, so the build swaps its Node-only imports for src/deploy/shims/node.worker.ts, and the node: leak check now also catches bare builtin specifiers; the bundle grows to about 1.7 MB. The generated wrangler.toml documents the KV binding and both secrets. The wrangler dev test 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) on AgentRun (from agent.stream(), session.stream() and AgentExecutor.stream()), and InputQueue.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 with step.done 'steered' and counts toward maxSteps; a provider that ignores the signal is not waited for). Once the model has emitted output, the input waits for the next safe point like enqueue() ('queued'); after the run has finished, applied is false. 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 type SteerResult; joined is a promise of whether the input reached the transcript). New event input.steered { id, text, mode: 'immediate' | 'queued' } (InputSteeredEvent, additive, schema v1), followed by the existing input.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 a send() / stream() made while a turn runs steer that turn ('queue' and 'wait' are unchanged). AgentRun gains the required steer() member, so a hand-written AgentRun object must add it. See docs/streaming.md#steering and docs/sessions.md.
  • ai v6/v7 stream() (LOU-D27): the built-in providers’ stream() (and so agent.stream()) now works on ai v6 and v7 as well as v4, through the LOU-D26 compatibility layer (streamCompat()): the request is mapped as for generate(), and either major’s fullStream is read into the same StreamChunks: text deltas, tool calls (the whole call, or one assembled from tool-input-start/-delta/-end when the model sends none), and a finish chunk with the finish reason and usage, including cached and reasoning tokens. An error part now rejects the stream with its error on both majors (on v4 the step used to end with finish reason error and the partial text), an abort part with the signal’s reason; reasoning deltas are dropped until a reasoning chunk type exists (LOU-V13). StreamChunk/StreamResult are unchanged. The protected AiSdkProvider.convertMessages() now returns our structural AiSdkMessage[] instead of ai v4’s CoreMessage[] (which v6/v7 do not export) and is marked deprecated for LOU-D28; an override returning CoreMessage[] still compiles. The ai/@ai-sdk/* peer ranges still name v4 (LOU-D28). Tested on the real ai v7 (MockLanguageModelV4), with agent.stream() events asserted by one helper on v4 and v7. See docs/providers.md#vercel-ai-sdk-versions.
  • spec.policy is enforced (LOU-X5): specToAgent() compiles the policy block into createAgent() options through the new compilePolicy(). requiresApproval (true or a list of tool names) becomes permissions: [ask(...)]; guardrails (max-length, secret-scan, regex, deny-topics, llm-judge, by name or { name, ...options, on }) become input and output guardrails; limits, askQuestion and compaction pass 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), and lousho doctor <spec> prints one line per policy block and flags unknown guardrail names. Behaviour change: requiresApproval: true now 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 example diff-size-cap) now fails validation. See docs/configuration.md#policy-policy.
  • ai v6/v7 generate() (LOU-D26): the built-in providers’ generate() now works on ai v6 and v7 as well as v4, through a compatibility layer that detects the installed major (v5+ exports stepCountIs). On v6/v7 it sends messages as ModelMessages (tool calls with input, tool results as output, mediaType), maxTokens as maxOutputTokens, one step as stopWhen: stepCountIs(1), tools as inputSchema without execute and responseFormat as a JSON-mode output, and reads usage from inputTokens/outputTokens/totalTokens (cached and reasoning tokens from their details) into the same GenerateResult. LLMProvider, GenerateOptions and GenerateResult are unchanged, and v4 behaves as before. stream() on v6/v7 throws an error naming LOU-D27 until that ticket lands; the ai and @ai-sdk/* peer ranges still name v4 (LOU-D28). Tested against the real ai v7 (MockLanguageModelV4) next to the v4 contract tests. See docs/providers.md#vercel-ai-sdk-versions.
  • Queued follow-up input (LOU-V9): run.enqueue(input) on AgentRun (from agent.stream(), session.stream() and AgentExecutor.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 }: applied is false when 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 events input.queued { id, text } and input.applied { id, step } (additive, schema v1). Non-streaming callers pass an InputQueue (new export) as ExecuteOptions.inputQueue and call its push(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 a send() / 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). AgentRun gains the required enqueue() member, so a hand-written AgentRun object 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? } on createAgent() and ExecuteOptions (new type AgentGuardrails). A guardrail is { name, check(ctx) } (new type IoGuardrail; ctx is { 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 its text.done); tool guardrails on a call’s arguments after the permission rules (skipped on deny) and before needsApproval. A block ends the run with the new finishReason: 'guardrail' and result.guardrail ({ name, kind, reason, toolName? }, new type GuardrailTrip), with no model call for an input block; onTripped: 'throw' rejects with the new GuardrailError (LOUSHO_GUARDRAIL_TRIPPED) instead. A rewrite replaces the text. stream() emits the new guardrail.tripped (before run.done) and guardrail.rewrote events. 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 as SECRET_PATTERNS), denyTopicsGuardrail({ topics }) and llmJudgeGuardrail({ model, instruction }). The patch guardrails (runGuardrails(), secretScanGuardrail, …) are unchanged; the new types are named IoGuardrail* because Guardrail and GuardrailResult are theirs. spec.policy.guardrails in agent spec files is not wired yet (LOU-X5). See docs/guardrails.md#input-and-output-guardrails.
  • Deployed /chat upgrade (LOU-D14): the node-server and docker targets (dist/server.js) now serve the lousho dev protocol: POST /chat { sessionId, input } streams the turn as SSE (data: <AgentEvent JSON>, then event: done) with history kept per session, GET /chat/:sessionId, POST /chat/:sessionId/approvals/:id ({ approved, note? } or { answer }) and GET /health; the single-turn POST /chat { message } still works (now with a Deprecation: true header). The routes are one shared module, src/server/chatRoutes.ts, used by lousho dev and the deployed runtime. LOUSHO_STORE (memory, the default, or sqlite:<path>, Node >= 22) picks where sessions live. Bearer auth: set LOUSHO_API_TOKEN (or pass { auth: { token } } as the new optional third argument of adapter.scaffold(); the variable wins) and every route except /health requires Authorization: Bearer <token> (constant-time compare, 401 JSON otherwise); treat it as required for anything not on localhost. The docker image is now based on node:22-slim (was node:20-slim) for node:sqlite. specToAgent(spec, { store }) takes an optional store. 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 like lousho dev and reads one turn per line through agent.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 an ask_question call, 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 is runChatRepl({ input, output, createAgent }) in src/cli/chatRepl.ts (testable with scripted lines and a mockModel agent); loadTarget() is now exported from src/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, and createAgent({ memory: [slot] }) wires it in. On the first model call of every run (send(), stream(), each session turn), a preGenerate hook recalls the slot’s newest recall.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: false turns recall off). The model gets remember_<name>({ text }) and recall_<name>({ query?, limit? }) tools (expose: { remember?, recall? }, both on by default). scope is 'global', 'session' (keyed by agent.session({ id })’s id or send()’s sessionId) or a function of { sessionId, metadata }; the new SendOptions.metadata feeds it. Providers implement MemoryProvider (list / add / remove); built in are inMemoryMemory() and fileMemory({ dir }) (one JSON file per scope key), both bounded by maxItems (default 1000, oldest dropped). See docs/memory.md.
  • Budgets (LOU-V6): limits?: { maxTokens?, maxInputTokens?, maxOutputTokens?, maxCostUsd?, maxDurationMs?, maxSteps?, onExceeded? } on createAgent() and ExecuteOptions (new type RunLimits). Limits are checked before every model call (so after every tool batch) and after a model call that asks for tools, and maxDurationMs aborts an in-flight model or tool call through the run’s signal. A tripped limit ends the run with the new finishReason: 'budget-exceeded' and result.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 typed budget.exceeded event (BudgetExceededEvent) before run.done (additive, the event schema version stays 1). onExceeded: 'throw' rejects with the new BudgetExceededError (LOUSHO_BUDGET_EXCEEDED, no longer reserved) instead. limits.maxSteps is an alias of maxSteps (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 as metadata.sessionUsage on the transcript’s last message (new types BudgetSpent, SessionBudget, SessionTurnOptions; ExecuteOptions.sessionBudget carries it to the run), so maxCostUsd per session holds across processes. See docs/configuration.md#budgets.
  • Stateful streaming dev chat (LOU-D32): lousho dev serves one session per browser tab. POST /chat with { sessionId, input } streams the turn as SSE (data: <AgentEvent JSON> per event, then event: done) through agent.session({ id, store }).stream(input) over an in-process memoryStore() (or the agent’s own store, when a module’s createAgent() options or overrides set one); GET /chat/:sessionId returns the transcript and the pending approval; POST /chat/:sessionId/approvals/:id with { approved, note? } or { answer } decides an approval or answers a question and streams the continuation (rebuilt from the continued run’s result, since agent.approvals.resolve() is not streamed). The chat page renders text deltas, tool calls with arguments and results, Approve / Reject and question buttons, errors and run.done usage and cost, with a “New session” button and the id kept in sessionStorage. Sessions survive a hot reload and continue on the new agent. The { message } body of POST /chat still works and is deprecated (no session, JSON ExecutionResult, Deprecation: true header). 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 dev for 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/.js module whose default (or agent) export is a SimpleAgent or a createAgent() options object, detected by path type and extension (a bad path, extension or export fails with LOUSHO_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 and ready() before it replaces the old one, which is then close()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 new GET /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 and useLoushoAgent().send() take the new exported AgentInput (string | ContentPart[] | Message[]): a string or parts become one user message, a Message[] 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. SqliteStore sessions and checkpoints save Uint8Array parts as { "$bytes": "<base64>" }, as FileSessionStore does. Finishes LOU-V11 (its note that agent.send() / session.send() take no parts is out of date). See docs/providers.md#multimodal-input.
  • Built-in ask_question tool (LOU-X9): createAgent({ askQuestion: true }) (off by default) or askQuestionTool() gives the agent ask_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 optional kind: 'question' and question: { text, options?, allowFreeText? } (new types ApprovalKind, ApprovalQuestion; describeApproval() and ASK_QUESTION_TOOL_NAME are exported), and so do the approval.requested event and the React hook’s pendingApproval. The new agent.approvals.answer({ id, answer }) (the same as resolve({ id, approved: true, note: answer })) continues the run with { answer, option? } as the tool result (option: index of the matching option); approved: false gives the model a kind: 'rejected' error saying the user declined to answer. useLoushoAgent() gets answer(text). The approve callback may now return a string (ApproveToolCall returns boolean | string), which approves with that note, so code can answer questions. A tool run after an approval sees the decision’s note as ctx.approval.note (new optional ToolExecutionContext.approval). See docs/approvals.md “Asking the user a question”.
  • Agent Forge time travel API (LOU-D45): Agent Forge’s FileCheckpointStore keeps the bounded per-session checkpoint history (history(), historyLimit option, delete({ keepHistory })) with the same semantics as LocalStorageCheckpointStore, checked by the SDK’s history contract suite. The runtime control server gains GET /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 with AgentExecutor.fork() and starts the fork as a run of its own) and GET /runs/compare?a=&b= (compareTrajectories()). Fixed on the way: Agent Forge’s chat transcript compiles against multimodal Message.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 of fromStep in the session’s checkpoint history, applies patch (messages(messages), businessState, toolResult: { toolCallId, result } replaced in place so the transcript stays provider-valid, appendInput queued as a user message) and saves it as the 'in-progress' checkpoint of newSessionId (default <sessionId>.fork-<n>), leaving the original session untouched; it returns { sessionId, step, checkpoint }, and execute({ sessionId, checkpointStore, input: [] }) continues the fork. A missing step throws the new LOUSHO_CHECKPOINT_NOT_FOUND code. createAgent() agents gain agent.fork(sessionId, { fromStep, newSessionId?, patch? }) over store.checkpoints (resume with agent.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 the lousho eval --drift diff. New types ForkOptions, ForkPatch, ForkResult, TrajectoryComparison, TrajectoryStep and DriftEntry. See docs/durable-execution.md#fork-and-replay.
  • Multimodal message parts (LOU-V11): Message.content is now string | ContentPart[], where a ContentPart is { type: 'text', text }, { type: 'image', image, mimeType? } (an http(s) URL, a data: URL or a Uint8Array) or { type: 'file', data, mimeType, filename? } (new types ContentPart, TextContentPart, ImageContentPart, FileContentPart). The built-in providers map the parts of user messages to the ai v4 user-content parts: images go to the model with all four (the ai SDK downloads an image URL first for Anthropic and Ollama); file parts become a [file <name> (<mimeType>) not sent] text note with a one-time console.warn, since the pinned peers (@ai-sdk/* 0.0.x, ollama-ai-provider) cannot send files (a subclass sets acceptsFileParts = true to send them). system, assistant and tool messages are sent as their text. The new textOf(message) returns a message’s text (the string, or its text parts joined), and is what estimateTokens() (each image or file part counts 1,000 tokens), compaction, recordReplay() fingerprints (cassette messages gain an optional attachments list: each image or file as its URL or a digest of its bytes), mockModel(), MockLLMProvider, OpenTelemetry content attributes and the React reducer (ui.send takes parts too) now read. FileSessionStore saves Uint8Arrays as { "$bytes": "<base64>" } and loads them back. Pass multimodal messages as AgentExecutor.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[] on createAgent(), ExecuteOptions and resumeAfterApproval() 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 and preToolCall hooks, before needsApproval) and the first match decides: allow runs the call without approval even when needsApproval would ask, deny gives the model a tool error with the new kind: 'denied' (ToolDeniedError, plus reason) and streams tool.error, ask pauses for approval (or asks approve). No match keeps today’s behaviour. New helpers allow(tools), deny(tools, reason?), ask(tools) and types PermissionRule, 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 (args left out under redactContent), and streams get the same entry as the new typed permission.decision event (PermissionDecisionEvent; additive, the event schema version stays 1). Neither is produced unless permissions or onPermissionDecision is set. Sub-agents inherit the lead’s rules (checked before their own) and its onPermissionDecision. See docs/approvals.md#permission-policies.
  • Compaction events and the compaction option (LOU-W3.2): agent.stream(), session.stream() and AgentExecutor.stream() emit two new typed events whenever the compaction hook compacts a request: compaction.start (strategy, tokensBefore, contextWindow, thresholdTokens) before the work and compaction.done (strategy, tokensBefore, tokensAfter, prunedToolCallIds, summary?, error?: { message }) after it, inside the step and before the model call; error is set when the strategy failed and the hook fell back. Additive: the event schema version stays 1. GenerateHookContext gains the optional emit(event) (set in streamed runs only) that the hook uses. createAgent({ compaction }) takes true (the prune hook with its defaults) or { strategy?, thresholdPercent?, contextWindow?, protectedTokens?, summarizer? }, where summarizer (a 'provider/model' string or an LLMProvider) selects twoPhaseStrategy() with that model; summarizer together with strategy is a ConfigurationError. createAgent({ hooks }) (new) takes any AgentHook[], 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/mcp and 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 (default true) reconnects on the next tool call after close() or a dropped connection. onError: 'skip' leaves a failing server out with a warning instead of rejecting. createAgent({ mcpServers }) connects them on the new agent.ready() or the first send() / stream() and adds their tools; the new agent.close() disconnects them (both are no-ops without mcpServers). specToAgent() now connects spec.mcpServers the same way (closes TODO(LOU-D20.2)); agent.mcpServers still lists them. loadMcpTools() accepts any { listTools, callTool } (McpClientLike). See docs/configuration.md#connect-mcp-servers-mcpservers-connectmcp.
  • Error codes with fixes (LOU-D2): every SDKError now has a stable code (LOUSHO_<AREA>_<NAME>, e.g. LOUSHO_CONFIG_MISSING_PROVIDER, LOUSHO_PEER_MISSING, LOUSHO_SPEC_INVALID, LOUSHO_SESSION_AWAITING_APPROVAL), a one-sentence hint and a docs link to its section of the new docs/errors.md; its message ends with a [code] hint (docs) line (detail is the message without it), and toString() always shows it. ERROR_CODES / ErrorCode (new exports) are the registry, and a test keeps it, the codes used in src/ and docs/errors.md in sync. The plain Errors thrown by createAgent() (no model, instructions + prompt, sessionId without checkpoints), resolveProvider() (bad spec, unknown prefix, missing API key, missing peer), AgentExecutor.execute() / stream() (missing provider / agent / input, approval with no approvalStore), resumeAfterApproval() (unknown approval), sessions (invalid id, corrupt file, second iteration, no stream runner), loadSpec() and specToAgent() are now ConfigurationError / ValidationError / SDKError with codes; MissingPeerDependencyError extends SDKError. 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(), SqliteStore and LocalStorageCheckpointStore keep a bounded history per session: every save() also appends the record to a ring (historyLimit option, default 50, 0 keeps none; the oldest are dropped), and the new optional CheckpointStore.history(sessionId, { limit? }) returns { step, savedAt, status, checkpoint } entries newest first. CheckpointStore.delete(sessionId, { keepHistory: true }) keeps the history (by default delete() clears it too), getCheckpointHistory(store, sessionId) reads it and returns undefined for a store without history(), and SqliteStore gains the checkpoint_history table (added when an existing database file is opened; prune() removes old entries). New types CheckpointHistoryEntry, CheckpointHistoryOptions, CheckpointDeleteOptions and MemoryStoreOptions. KVCheckpointStore and 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): --record runs every eval case against its agent’s real provider through recordReplay() and writes one cassette per case next to the eval file (__cassettes__/<eval-name>/<case>.json, the normal cassette format); --replay runs every case from its cassette with no network, and a missing cassette fails the case with the --record command to run. Plain lousho eval with CI set replays the cases that have a cassette; without CI it is unchanged. --drift re-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 a Drift: table and each drifted case a soft drift assertion (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. EvalResult gains cassettes. Fixed on the way: trajectory and classic eval results now carry their file (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 metrics gen_ai.client.token.usage (histogram, {token}, one record per gen_ai.token.type input/output) and gen_ai.client.operation.duration (histogram, s, for every model call and tool call) through @opentelemetry/api’s metrics API: the global MeterProvider, or the new meter option; metrics: false turns them off. Without a registered MeterProvider they are discarded by OpenTelemetry’s no-op meter. Spans gain lousho.cost_usd (estimated USD: per step on chat spans, cumulative on invoke_agent spans, absent when the price table does not know a model) and lousho.usage.estimated on invoke_agent spans (it was already on chat spans). Any TraceExporter sees the cumulative attributes: withSpan() sums the cost of finished child spans onto their parent. See docs/observability.md.
  • One store option (LOU-D30): createAgent({ store }) takes an AgentStore ({ sessions?, checkpoints?, approvals? }, new export) and wires all three from it. SqliteStore is one; the new memoryStore() 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 in store.sessions and checkpoints every turn in store.checkpoints; store.approvals is the default approvalStore. SendOptions.sessionId makes send() / stream() a durable run checkpointed in store.checkpoints (an error without one), so a one-shot durable job needs no AgentExecutor, and agent.approvals.resolve() keeps checkpointing it. The new agent.resume(sessionId) finishes an interrupted send(..., { sessionId }) run, or else the session’s pending turn (as session.resume()), and returns null when nothing is pending. A session’s own store / checkpointStore and an explicit approvalStore still win, part by part. Without store, 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 flagged isError), whichever path failed: kind is 'execution', 'validation' (plus issues), 'not-found', 'rejected' (plus note), 'not-run', 'mcp' or 'sandbox'. New exports toolErrorResult(), ToolErrorResult, ToolErrorKind and ToolErrorInput; McpToolError and the sandbox guard’s error (named SandboxRequiredError, not exported) carry a toolErrorKind. kind is 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 reaches onEvent afterwards. withSubagentOptions(subagents, { awaitBackgroundOnFinish: true }) waits for them instead (an aborted or failed lead still cancels them). The new result.backgroundTasks lists 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 with withSubagentOptions(), without changing the subagents value). The executor hook behind it is public: ExecuteOptions.onRunEnd({ result } | { error }) is called exactly once per execute() / 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 a store that carries both, { sessions, checkpoints }, such as a SqliteStore) runs each turn with sessionId: '<id>.turn-<n>' and that CheckpointStore, 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 a PropagatingToolError (also in a new process) without re-running recorded tool calls or model responses, adds it to the transcript as send() would and returns its result (null when nothing is pending); session.pending() reports an unfinished turn and session.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() throw SessionAwaitingApprovalError, and agent.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 }) (and ExecuteOptions.output) makes the final reply a JSON object validated by the schema. send(), run.result and agent.approvals.resolve() resolve with it as result.object, typed z.output<typeof schema> from send() and stream() of createAgent() agents (SimpleAgent, AgentRun and ExecutionResult gain a type parameter that defaults to unknown); result.text keeps the raw JSON. The system prompt gets an ## Output format section with the schema as JSON Schema, and each model call carries the new GenerateOptions.responseFormat: { type: 'json', schema? } hint, which the ai-SDK providers map to experimental_output JSON mode. The reply is parsed (a code fence is tolerated) and validated; when invalid, one repair step sends the model the issues (it counts against maxSteps), and a reply still invalid ends the run with the new finishReason: 'output-invalid', no object and outputError: { message, issues }. run.done gains an optional object field (additive: the event schema version stays 1). See docs/structured-output.md.
  • Background sub-agents (LOU-Y4): the task tool takes background: true, which starts the sub-agent and returns { taskId, status: 'running', agent } at once. Agents with subagents also get agent_status({ taskId? }) (queued/running/done/failed/cancelled/awaiting-approval, plus elapsedMs), agent_await({ taskId | taskIds, timeoutMs? }) (the synchronous task result, or status: 'timeout') and agent_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 and maxSubagentDepth, and aborting the lead cancels them. Not yet: resuming one that paused for approval (it reports awaiting-approval with its approvalId). A user tool named agent_status, agent_await or agent_cancel now conflicts with subagents, like task. See docs/sub-agents.md.
  • React hook (LOU-D15): new @lousho/build-ai-agent/react subpath with useLoushoAgent(source, options?). source is { agent, sessionId? } (in process, via agent.stream() or agent.session({ id }).stream()) or { url, headers?, fetch? } (POSTs { input } and reads the SSE or NDJSON event stream). It returns messages (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?) and reject(note?) (via agent.approvals.resolve() in process, or POST ${approvalsUrl}/${id} remotely). The framework-neutral reduceAgentEvents() reducer and parseEventStream() 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 by model (an LLMProvider or 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 reports error. twoPhaseStrategy() (the recommended strategy) prunes first and summarizes only when still above the threshold. pinMessage() / isPinned() and the new optional Message.metadata (never sent to providers) mark messages no built-in strategy compacts. CompactionStrategy.compact() may now be async, so compactMessages() returns a Promise; CompactionResult / CompactionInfo gain summary and error, and CompactionInput gains thresholdTokens and signal. A strategy that throws no longer fails the run: the hook reports the error to onCompaction. See docs/compaction.md.
  • createAgent({ retry, fallbackModels }) (LOU-V7.2): retry (withRetry() options, or false) retries failed model calls and defaults to { maxRetries: 2 } for models given as provider/model strings (or picked from the environment); a provider instance is wrapped only when retry is set. fallbackModels are provider/model strings tried in order once the primary model’s retries are used up (withFallback([withRetry(primary), withRetry(fallback), ...])). agent.stream(), session.stream() and AgentExecutor.stream() report them as two new typed events, provider.retry (attempt, maxRetries, delayMs, error: { message, category? }, provider) and provider.fallback (from, to, error: { message }), for any withRetry() / withFallback() provider. Additive: the event schema version stays 1.
  • Context compaction (LOU-W2): createCompactionHook() returns an AgentHook that, before a model call estimated above thresholdPercent (default 0.9) of the model’s context window, replaces tool results older than the newest protectedTokens (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 (stdio command/args/env or HTTP url/headers) in agent spec files. loadSpec() reports a bad entry with its name, lousho doctor reads the validated field instead of the raw file, and specToAgent() exposes the parsed servers as agent.mcpServers (connecting them is TODO(LOU-D20.2)). Existing specs are unaffected.
  • createAgent() agents can pause for approval (LOU-D21): a needsApproval tool no longer fails the run with “requires approval but no approvalStore”. The run pauses (finishReason: 'awaiting-approval') in a per-agent InMemoryApprovalStore (or the new approvalStore option), and agent.approvals.list() / agent.approvals.resolve({ id, approved, note? }) continue it, in its session if it paused in one. The approve option decides calls in code without pausing.
  • Tools own their contract (LOU-D22): ToolDescriptor gains optional inputSchema (zod) and execute(args, ctx), which are now the canonical fields. defineTool() sets both and no longer calls ai’s tool(). Argument validation, the schema sent to the model, and tool execution read them first and fall back to tool.parameters / tool.execute. ToolDescriptor.tool (the ai v4 Tool) is now legacy: it is still built by defineTool() and still accepted on hand-written descriptors this release, but new code should set inputSchema and execute.

Removed

  • BREAKING: validateTokenQuotas() (src/security/quotas.ts) and the SaaS-era types SaaSContext, QuotaConfig, UsageStats and QuotaValidationResult (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: ConfigManager and SDKConfig (src/core/ConfigManager.ts) (LOU-D36) no longer ship from the package root or ./core (which still exports AgentBuilder). 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 types CodeBlock, 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() and setRecursiveNames() (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() and ProcessFilesParams (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() and convertFromFlowDefinition() (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 and FlowExecutor never used them. Run flows with FlowExecutor directly.
  • BREAKING: MemoryManager and its types MemoryManagerConfig, StoreMemoryOptions, RecallMemoryOptions and MemorySearchResult (src/execution/MemoryManager.ts) (LOU-D38) no longer ship from the package root. Nothing in the SDK used them (AgentExecutor, createAgent, resume and the flows never called them; only their own test and the execution/index.ts re-export). Migrate to defineMemory() and createAgent({ memory }) (memory slots, see docs/memory.md).
  • BREAKING: ContextBuilder, ContextBuilderOptions and ExecutionContext (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 to createAgent({ 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 to createAgent({ retry, fallbackModels }), or copy the file into your project for general-purpose retries.
  • BREAKING: The data/ module (LOU-D39) is gone: the classes Agent, Session, Result, Memory, Attachment and the repository interfaces BaseRepository, AgentRepository, SessionRepository, ResultRepository, MemoryRepository, AttachmentRepository, RepositoryCollection, RepositoryFactory, PaginationOptions, PaginatedResult no longer ship from the package root. They were donor-product data tables that nothing in the SDK used once MemoryManager was removed. No replacement: unused. Use AgentConfig, sessions (agent.session()) and defineMemory() for the SDK’s own concepts.
  • BREAKING: @lousho/build-ai-agent/testing exports test utilities only (LOU-D39). The in-memory repository mocks MockAgentRepository, MockSessionRepository, MockResultRepository, MockMemoryRepository and MockAttachmentRepository are removed with the data/ module. No replacement: unused. mockModel and recordReplay are unchanged.
  • BREAKING: The template engine (TemplateManager, renderTemplate() and the types TemplateFilter, TemplateContext, TemplateOptions, ITemplateManager; src/templates) (LOU-D37) no longer ships from the package root. Nothing in the SDK, lousho init or Agent Forge used it (the executor never rendered prompts through it; the scaffold templates are separate files under src/cli/init). No replacement: unused; use a template literal or your own renderer.
  • nanoid is no longer a dependency (LOU-D35). Generated ids (agent, run, trace, memory ids) now come from globalThis.crypto.randomUUID() via an internal newId(), so they are UUIDs instead of 21-character nanoids. Nothing public depended on the old format.
  • finishReason: 'max-steps' (LOU-U19): a run that exhausts maxSteps while the model still wanted to continue (last turn ended in tool calls) now resolves with finishReason: 'max-steps' instead of the stale last-turn reason (usually 'tool_calls'), on ExecutionResult, the finish event and run.done. Resumed runs count initialSteps toward 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 eval completed() 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 ai 5+ the providers send images as file parts with an image mediaType (image/* when unknown) instead of the deprecated image part, which made ai 7 log a deprecation warning for every image. ai 4 still gets image parts.
  • OpenRouter on @ai-sdk/openai 2+, and the Ollama base URL (LOU-D28f, behaviour fix): OpenRouterProvider now builds its model with provider.chat(modelId) (Chat Completions, the only API OpenRouter implements) instead of the bare call, which targets the Responses API from @ai-sdk/openai 2 on; no change on ai 4. OllamaProvider appends /api to a base URL that has no path (http://host:11434 or http://host:11434/), the form ollama-ai-provider and ollama-ai-provider-v2 expect, so a configured bare host no longer hits /chat outside /api (before, getModels() hit <baseURL>/api/tags but chat requests went to <baseURL>/chat); a URL ending in /api or with any other path is used as it is, and getModels() no longer doubles /api for a base URL that has it. lousho init (and create-lousho-agent) now scaffolds OpenRouter projects on ai@^7.0.0 with @ai-sdk/openai@^4.0.0.

Changed

  • zod 3 or zod 4 (LOU-D29): the zod peer is now ^3.25.76 || ^4.0.0. Schemas of either major (including zod/v4 on zod 3.25 and zod/v3 on zod 4) work in defineTool({ input }), structured output and argument validation: zod 4 schemas are sent to the model as z.toJSONSchema output (also to ai v4, whose own converter only reads zod 3), zod 3 schemas through the ai SDK’s converter as before, and both are parsed with their own safeParse. defineTool also 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’s Invalid input: expected string, received undefined is reported as Required). lousho init keeps zod 3 for the ai 4 scaffold (Ollama), whose packages peer on zod 3. Ollama on ai 6/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, DefinedTool and ToolDescriptor.inputSchema are typed with the new exported StandardSchemaV1 (parsed type: InferSchemaOutput) instead of zod 3’s ZodTypeAny; code that called zod methods on descriptor.inputSchema needs a cast. The task tool’s error for an unknown sub-agent now reads Unknown sub-agent "x". Expected one of: 'a', 'b'. createAgent({ output }) is still typed with the installed major’s ZodType.
  • 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: pass approvers: () => true for 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 an approvers list 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, /mcp and the rest import shared chunks instead of each bundling their own copy. HookRegistry, SDKError and module-level registries are now the same object across entries (they were duplicated, so instanceof and singletons could disagree), and dist/ shrinks from about 25 MB to 8.8 MB. instanceof SDKError and instanceof HookRegistry also hold between the ESM and CJS copies a mixed-format process loads (a Symbol.for brand). See Installation.
  • A crash resume of a dynamic agent restores the run’s config (LOU-V15.2): checkpoints now carry runConfig (the ctx and model the run was resolved with, as approval snapshots already did), and agent.resume(id) / session.resume() / send(…, { sessionId }) on an unfinished run use the saved model and re-resolve tools and instructions with the saved ctx, instead of input: [] and no metadata; a model chosen from metadata no 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 changed ctx.args in place, and a difference is refused with the existing kind: '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 with LOUSHO_RESUME_TOOL_MISSING instead of giving the model a not-found tool 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 flagged isError. Before, the result was { error: '<text>' } (and the cancelled one was not flagged isError); the text is now under message.
  • A permissions allow (or ask) rule no longer overrides a tool’s own needsApproval deny (LOU-X8 follow-up, behaviour change): the tool’s needsApproval is now called for those calls too, and its 'deny' / { deny } denies the call; an allow still skips the tool’s ask. A deny rule still wins without calling needsApproval. Migration: a tool that should run under an allow rule must not deny it from needsApproval. See docs/approvals.md.
  • agent.session() passes the agent’s createAgent({ compaction }) to the session (LOU-W8 follow-up), so session.compact() uses the agent’s strategy and sizes unless agent.session({ compaction }) sets its own.
  • The cloudflare-worker target builds with ai v7 installed (LOU-D28c). With ai v7 (@ai-sdk/openai/@ai-sdk/anthropic v4), lousho build --target=cloudflare-worker failed with “Node builtins leaked” (node:module, node:dns, node:diagnostics_channel, node:async_hooks). Those are not imports: ai v7 and @ai-sdk/provider-utils v5 probe them at run time through process.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 a getBuiltinModule/loadBuiltinModule call; any other node: reference still fails the build. No nodejs_compat flag is needed. The 16 Worker bundle tests that LOU-D28b skipped on ai v7 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() and SandboxShell no longer see the host’s process.env, so env/printenv cannot reveal API keys or cloud credentials. They get a small base a shell needs (PATH, HOME/USERPROFILE, TMP/TEMP/TMPDIR, LANG, LC_*, TERM, plus SystemRoot, SystemDrive, ComSpec, PATHEXT, WINDIR on Windows) and what you add. A container (SubprocessSandbox) gets only what you add, never the host env. SandboxShell gains env and inheritEnv options; NodeWorkspaceOptions.inheritEnv and SandboxShellOptions.inheritEnv accept true; SandboxRunOptions gains inheritEnv (NoopSandbox keeps passing the host env unless it is false, which SandboxShell sets). SubprocessSandbox gains network: 'none' | 'default' | { allow: string[] } (default 'none', as before); { allow } is validated and kept on sandbox.network but 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, pass inheritEnv: true. See docs/workspace-tools.md.
  • Internal (LOU-D28b): the test suite runs with ai v7 installed as ai. 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 leaks node: built-ins on v7 (LOU-D28c) are skipped there. The typecheck-ai7 CI job now also builds and runs npx vitest run. Test and CI change only; no product change.
  • Internal (LOU-D28a): the SDK source now type-checks against ai v7 (with @ai-sdk/openai/@ai-sdk/anthropic v4) as well as ai v4. AiSdkProvider builds its ai v4 request with SDK-owned structural types instead of the v4-only CoreMessage/CoreAssistantMessage/CoreToolMessage/Output types and no longer calls ai’s tool() (an identity function on v4); the legacy .tool that defineTool()/toolDescriptorFromSchema() hand-build is cast once at that boundary. No runtime change on v4. A new CI job runs tsc --noEmit with ai@7 installed. The ai/@ai-sdk/* peer ranges are unchanged for now (LOU-D28d).
  • MCP tools without readOnlyHint now ask for approval by default (LOU-Z5, BREAKING for MCP users): loadMcpTools(), connectMcp() and createAgent({ mcpServers }) set needsApproval from each tool’s MCP annotations: readOnlyHint: true runs, destructiveHint true or absent (the MCP spec default) asks, destructiveHint: false runs. Choose per server with the new approval: 'annotations' | 'always' | 'never' | ({ name, annotations }) => boolean (loadMcpTools(client, name, { approval }), connectMcp() server entries, createAgent({ mcpServers }) and AgentSpec.mcpServers, which validates it; a spec file takes the three strings). The descriptor keeps the raw annotations on the new ToolDescriptor.metadata?.mcp.annotations (types McpToolAnnotations, ToolMetadata, McpApproval) and uses the annotation title as displayName. Migration: pass approval: 'never' to restore the old behaviour (every MCP tool runs without asking). createAgent() agents pause with finishReason: 'awaiting-approval', while a bare AgentExecutor without an approvalStore now 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 descriptors loadMcpTools() builds no longer call ai’s tool(). They now carry canonical inputSchema / execute (the legacy .tool stays), and the built-ins have the tool names current_date, day_name, send_email, http_request, slack_alert and delegate_to_<agent>. Descriptions, schemas, approval and sandbox behaviour and the factory signatures are unchanged. MCP descriptors are built with the new internal toolDescriptorFromSchema(), which (unlike defineTool) accepts any server-provided name or empty description. routeFetchThroughSandbox() reads the canonical execute first.
  • GitHub and Jira tools are defined with defineTool (LOU-D25, not breaking): the 28 createGitHubTools() tools and the 20 createJiraTools() tools no longer call ai’s tool(), so nothing under src/tools imports ai any more. They now carry canonical inputSchema / execute (the legacy .tool stays). 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 canonical execute as well as tool.execute, so calling one still throws “out of scope” before any HTTP request. JiraTools.register() now also routes tools registered with defineTool through the sandbox seam.
  • BREAKING (types only): Message.content is widened from string to string | ContentPart[] (LOU-V11). Messages you build keep working unchanged, but code that reads message.content as a string (.length, .startsWith(), a template literal) no longer type-checks. Migration: read textOf(message) instead, or narrow with typeof message.content === 'string'. A custom LLMProvider that forwards content should handle the parts or send textOf(message).
  • The execute context is our own type (LOU-D23, not breaking): the new exported ToolExecutionContext ({ toolCallId, messages: readonly Message[], abortSignal?, sessionId?, onDelegatedUsage? }, Message from @lousho/build-ai-agent) replaces the ai SDK’s ToolExecutionOptions as the type of the second argument of defineTool()’s execute, ToolDescriptor.execute, DefinedTool.execute and the third argument of sandboxExecute, and is what buildToolRunContext() returns; the public types no longer import ToolExecutionOptions from ai. A function typed for ai’s options that only reads toolCallId or abortSignal stays assignable. ToolRunContext is now a deprecated alias of the optional fields of ToolExecutionContext (it already existed with onDelegatedUsage and toolCallId). AgentExecutionOptions.messages is typed Message[] instead of the ai SDK’s CoreMessage[] (the field is unused by the SDK). The legacy ToolDescriptor.tool (ai v4 Tool) is unchanged. See docs/tools.md#execute-context.
  • BREAKING (small): SDKError.code values are now the LOUSHO_* 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; code is no longer optional (new SDKError(message) gets LOUSHO_GENERIC_ERROR). No class was renamed. The messages of SDKErrors other than ToolExecutionError, LLMProviderError, TimeoutError and RateLimitError gain a second line with the code, hint and docs link, and loadSpec() 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: compare code against the new values (or use instanceof); match messages with toContain / a regex, or compare error.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 error is 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 reject resumeAfterApproval() with Tool 'x' not found in registry; it now resolves with a not-found error result. The sandbox guard throws SandboxRequiredError (same message, still an Error). Migration: read message instead of error when you want the text, and branch on kind. ToolArgumentsValidationError.toToolResult() now also returns kind: '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_await before its final answer, or set subagentOptions: { awaitBackgroundOnFinish: true }.
  • Heavy dependencies are optional peers (LOU-D40): dockerode (Docker sandboxing, SubprocessSandbox), @modelcontextprotocol/sdk (serveMcp, lousho mcp, MCP clients) and prompts (the interactive questions of lousho init) moved from dependencies to peerDependencies, marked optional in peerDependenciesMeta. 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 with MissingPeerDependencyError, which now also carries feature and names the exact command (for example npm install dockerode@^5.0.1). lousho doctor lists the three with what they enable (a missing dockerode fails the check when the agent spec uses a sandboxed tool), and its version lookup no longer returns undefined for packages whose exports map hides package.json. Migration: install the peer if you use Docker sandboxing (npm install dockerode), MCP (npm install @modelcontextprotocol/sdk), or interactive lousho init (npm install prompts; npm create lousho-agent already does, and lousho init --yes needs nothing). See docs/installation.md.
  • The http tool no longer loads undici for default requests (LOU-D40): with TLS verification on (the default) it uses the runtime’s global fetch; undici (still a regular dependency) is loaded only for validateSSL: false, whose per-request TLS dispatcher the global fetch cannot express. yaml stays 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 pass maxRetries to the ai SDK calls: config.maxRetries, or 2 (the ai SDK’s default, so behaviour is unchanged) when unset. createAgent() builds the providers it resolves from strings with maxRetries: 0 and retries in its withRetry() 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 in withRetry() / resilientProvider() yourself, build it with maxRetries: 0; note that a maxRetries you already set in a provider config is now honoured by the ai SDK.
  • AgentType is optional and deprecated (LOU-D34, not breaking): AgentBuilder.build() no longer requires setType() and AgentConfig.agentType is now optional (createAgent() and specToAgent() agents carry no type). AgentType, setType() and the agent-types registry/validators are marked @deprecated: they have no runtime effect and will be removed in the next minor; they stay exported for now. Drop your setType(...) 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 from src/data/mocks.ts via the package root/./data subpath; this package has no createMockRepositories factory) are no longer exported from the package root. Import them from @lousho/build-ai-agent/testing instead.
  • One session-API client behind remoteAgent() and remoteTarget() (LOU-D53): both now share an internal client (src/server/sessionClient.ts) for POST <url>/chat, the SSE read, bearer-token scrubbing and the error mapping, and use one pair of codes, LOUSHO_REMOTE_REQUEST_FAILED and LOUSHO_REMOTE_UNAUTHORIZED. The unreleased LOUSHO_REMOTE_AGENT_FAILED (LOU-Y7) is removed: a remoteAgent() failure now carries LOUSHO_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) or LOUSHO_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)