Skip to content

v0.14.0

Choose a tag to compare

@themartto themartto released this 27 Sep 06:34
· 60 commits to main since this release
8c346fe

Added

  • The TUI can cancel a running turn. Press Ctrl+C while the agent is working to stop it. What had streamed so far stays on screen, followed by "turn cancelled". A pending permission prompt closes too. To quit, press Ctrl+C twice within two seconds (the footer shows "Ctrl+C again to quit" after the first press). :q still quits at once.
  • context_window for providers, OpenheimBuilder::context_window and AgentConfig::context_window: the model's context window in tokens, used to leave older turns out of requests before they're sent (see Fixed). Code that builds ProviderConfig or AgentConfig with a struct literal needs context_window: None.
  • read_file takes offset and limit to read a range of lines, so a file too long for one call can be read in parts (see Fixed).
  • Error::ContextOverflow, returned when a provider rejects a request as too long for the model and leaving out every earlier turn didn't help, meaning the current turn alone is too long. It isn't retried. Code that matches on Error exhaustively needs the new arm.
  • StreamEvent::ContextTrimmed { dropped }, sent when the next request leaves out the dropped oldest history messages to fit the context window. The TUI shows it as a notice; ACP sends nothing. Code that matches on StreamEvent exhaustively needs the new arm.
  • Error::IncompleteResponse, returned when a provider's streamed reply ends before the provider says it's finished. It's retryable. Code that matches on Error exhaustively needs the new arm.
  • Message::transcript() lists what a UI shows for a saved message, in order, as TranscriptEntry values (user text or image, thinking, assistant text, tool call, tool result). It skips system messages, redacted or empty thinking, and empty assistant text. The TUI and ACP session replay both use it, so a restored session shows the same things in both. Use it to render history from resume_session in your own UI.
  • ContentBlock::ToolUse has a signature field for an opaque token the provider needs back with the call (Gemini's thoughtSignature). It's optional and left out of saved history when unset, so existing history loads as before. Code that builds ToolUse with a struct literal needs signature: None, or can use the new ContentBlock::tool_use(id, name, arguments). Patterns that destructure it need ...
  • ContentBlock::RedactedThinking { data } for thinking Anthropic returns encrypted. It's stored and sent back unchanged, and there's nothing to display. Code that matches on ContentBlock exhaustively needs the new arm.
  • LongTermMemory::search_detailed returns a MemorySearch: the hits, plus whether the query couldn't be embedded (semantic_failed) and how many notes have no vector yet (unindexed). search still returns just the hits.
  • VectorStore::unembedded_up_to(limit), unembedded_count() and search_keyword_unembedded(query, k), for working with notes that have no vector yet.

Changed

  • The library facade has one way to prompt and one way to resume a session. There were four prompt methods and four resume/load methods that did the same things with different argument and callback types. Now SessionHandle::prompt takes anything that converts into the new PromptInput (a &str/String, or PromptInput::text(..).image(data, mime_type)), always hands your callback StreamEvents, and always returns the turn's StopReason. ACP vocabulary is an opt-in adapter (feature acp) instead of parallel methods. SessionHandle::id is now private; read it with id().

    Before Now
    session.prompt_events(text, cb) session.prompt(text, cb)
    session.prompt_events_with_images(text, images, cb) session.prompt(PromptInput::text(text).image(data, mime), cb)
    session.prompt(text, acp_cb) (returned ()) session.prompt(text, session.acp_updates(acp_cb)) (returns StopReason)
    session.prompt_with_images(text, images, acp_cb) session.prompt(PromptInput::text(text).image(..), session.acp_updates(acp_cb))
    client.load_session(id, cwd, acp_cb) / handle.restore(id, cwd, acp_cb) let (session, loaded) = client.resume_session(id, cwd).await?; then session.acp_replay(&loaded.messages, acp_cb)
    handle.resume(id, cwd) (kept the handle's gate and client I/O) client.resume_session(id, cwd), then .permission_gate(..) / .client_io(..) on the new handle
    session.id session.id()

    load_session/restore used to pass a fallback warning to the callback as an agent message. It's now only in LoadedSession::warning, so show it yourself.

  • The agent loop has one entry point, core::agent::run_agent. It replaces run_agent_with_history (pass |_| {} as the new last argument) and run_agent_streaming_with_history (same arguments). It takes the LLM client and tool executor as &dyn LlmClient / &dyn ToolExecutor instead of Arcs by value; with an Arc, pass &*llm.

  • Permission prompts say when a subagent is asking. A delegate_task subagent's tool calls went to the session's permission gate looking exactly like the parent's, so the user was asked to approve calls that never appeared in the transcript, with nothing saying where they came from. PermissionGate::check now takes one &PermissionRequest (tool_call_id, tool_name, arguments, and subagent, which is the subagent's name, or None for the session's own agent) instead of three strings. The TUI shows the subagent in the prompt, and ACP puts it in the request's title (execute_command (subagent 'reviewer')). To update your own gate, change check(&self, id: &str, tool_name: &str, arguments: &str) to check(&self, request: &PermissionRequest<'_>) and read the fields from request. PermissionRequest is #[non_exhaustive]; build one with PermissionRequest::new(id, tool_name, arguments).

  • ClientIo::read_file takes a line range: read_file(&self, path: &Path, lines: LineRange). LineRange (in core::client_io) has optional line (1-based) and limit fields; LineRange::default() is the whole file. read_file passes on the range the model asked for, and edit_file asks for the whole file. To update your implementation, add the parameter; returning the whole file regardless still works, since read_file caps what it returns.

  • Tools work in the session's cwd. A session's cwd (ACP session/new and session/load, SessionBuilder::cwd, or the directory openheim was started from) was only stored for session/list filtering, and every session's tools worked from work_dir. Now, when cwd is a directory inside work_dir, relative paths resolve there and execute_command runs there, the way a shell started in that directory would. work_dir is still the boundary for every path, and a cwd outside it (such as a client's own local path) is ignored in favour of work_dir. For embedders: TurnContext has a new cwd field, so code that builds one with a struct literal needs cwd: ... (use the work directory to keep the old behaviour). File-touching custom tools should resolve paths with the new TurnContext::resolve_path instead of sandbox::validate_path(path, turn.work_dir), which still works but resolves relative paths from work_dir. sandbox::validate_path_from is the same check with an explicit cwd.

  • The default Gemini model is now gemini-3.8-flash. The previous default, gemini-2.0-flash, has been shut down by Google, so a Gemini provider left on the built-in default no longer worked. Configs that name a model explicitly are unaffected.

Fixed

  • Long sessions keep working past the model's context window. Every request resent the whole history, so once a session outgrew the context window every prompt failed with a provider 400, and resuming the session didn't help. Now a request that doesn't fit leaves out the oldest whole turns, and the first kept message says how many were left out. The saved history keeps everything. With context_window set on the provider, this happens before a request is sent, when it's estimated at over 90% of the window. Without it, it happens after the provider rejects a request as too long (recognised for OpenAI, Anthropic, Gemini and the common OpenAI-compatible servers), with up to two retries per call. Tool calls are never separated from their results, and the turn in progress is always sent whole; if that alone doesn't fit, the turn fails with Error::ContextOverflow saying to start a new session.
  • Cancelling a turn while tools were running no longer breaks the session. The model's tool-call message was already saved, but the cancelled calls never got results, and Anthropic and OpenAI reject a history with an unanswered tool call, so every later prompt in that session failed. Cancelled calls are now recorded as a Cancelled by user. error, and calls that had already finished keep their results. Each cancelled call also gets a ToolResult event, so UIs stop showing it as pending. Sessions already saved in the broken state, or cut short by a crash mid-turn, work again too: the missing results are filled in on each request, without changing the saved history.
  • A tool call cut off at the output token limit no longer breaks the session. When a reply hit max_tokens partway through a tool call (a large write_file, say), the half-written arguments were saved to history. Anthropic and Gemini requests are built by parsing those arguments, so that request and every later one in the session failed. Arguments that aren't a JSON object are now sent to Anthropic and Gemini as {}, which also makes sessions already saved in that state work again. Such a call is no longer run or put to the permission gate: it gets an error result telling the model its arguments were malformed and, when the reply was cut off, to retry with smaller arguments.
  • One large tool result no longer pushes every later request past the context window. read_file, search, MCP tools and custom tools had no output limit, so reading one big file put all of it into the history, which is resent with every request. Every tool result is now cut to 128 KiB with a note saying how much was left out.
  • A missing skill no longer breaks a session. A session saved with a skill whose file was later deleted or renamed failed every turn, and so did a session started with a misspelled skill name. new_session (and ACP session/new) now fails with Error::NotFound for a skill that doesn't exist. A saved session's skill that has since gone is left out of the prompt with a warning in the log.
  • A reply cut off mid-stream is no longer taken as complete. When the connection dropped partway through a reply, the body just ended, and the built-in providers returned whatever had arrived as the finished answer. The turn then ended there, with half a reply saved and any tool call still in flight lost. Now a stream that ends without the provider's end marker (Anthropic's message_stop, OpenAI's [DONE] or finish reason, Gemini's finishReason) fails with Error::IncompleteResponse. It's retried like a network error while nothing has been streamed to the caller yet. Stream payloads that fail to parse are now logged as warnings rather than skipped silently.
  • An unreadable session lease no longer locks its session for good. A lockfile that wasn't a valid lease (for example, left empty by a crash right after it was created) made every later prompt on that session fail with SessionLocked ("pid 0 on unknown") until the file was deleted by hand. Such a file is now taken over once it's 10 seconds old; a newer one may still be a lease another process is writing, so it's respected.
  • Function calling works on Gemini 3. Gemini 3 needs two things back with each function call, and openheim sent neither. First, the call's thoughtSignature: without it, every request after a tool call failed with a 400 ("missing a thought_signature"). Second, the call's id on its functionResponse: without it, Gemini 3.x silently replies with nothing. Both are now kept from the reply and sent back as received. Calls Gemini didn't make itself (from before this change, or from another provider after a model switch) get the stand-in signature Google documents for this case, so those sessions keep working on Gemini 3 too. Gemini 2.5 accepts the new fields as well.
  • Anthropic replies with more than one thinking block are kept intact. Adaptive thinking is interleaved, so one reply can hold several thinking blocks between its text and tool calls, each with its own signature. They were merged into a single block with all signatures joined, redacted_thinking blocks were dropped, and the blocks were reordered as thinking, text, tools. Anthropic rejects such a turn when it's sent back, so the next request after such a reply failed. Each block is now stored separately, in order, and sent back as received. The TUI and ACP session replay show a restored turn's blocks in that same order.
  • An Anthropic tool call with no arguments no longer breaks the next request. When no input was streamed, its arguments were saved as an empty string, which isn't valid JSON; they're now {}.
  • MCP tool names are always accepted by the provider. Tool names were sent as the server reported them, and the server-name prefix kept non-ASCII letters, so one tool named e.g. fs.read, or a name over 64 characters, got every request rejected, since the tool list goes out with each one. In the exposed {server}__{tool} name, characters other than ASCII letters, digits, _ and (in the tool part) - now become _, a name starting with a digit gets a leading _, and a name over 64 characters is shortened and ends in a hash of the full name. Names that were already valid are unchanged.
  • max_tokens works with OpenAI's reasoning models. It was sent as max_tokens, which OpenAI has replaced with max_completion_tokens and which its o-series and GPT-5 models reject, so an openai provider with max_tokens set failed on them. openai-kind providers now send max_completion_tokens. openai_compatible ones still send max_tokens, since most compatible backends don't know the new name.
  • Gemini tool calls now have unique ids. They were numbered per reply (call_0, call_1, …), so every reply's first call had the same id as the one before it, and a subagent's calls could share ids with its parent's. ACP clients tell tool calls and permission requests apart by id, so a new call could replace an earlier one on screen. Gemini's own call id is used when it sends one, otherwise a random one. The same applies to OpenAI-compatible backends that stream a tool call without an id.
  • A Gemini reply blocked for safety now ends the turn as a refusal. Its final chunk has a finish reason but no content, and that chunk failed to parse and was ignored.
  • OpenAiClient::send and OpenAiCompatibleClient::send now stream, like the Anthropic and Gemini clients. They had their own non-streaming request and parser, which the agent loop never uses, and it had drifted from the streaming one: a tool call without an id failed to parse (some OpenAI-compatible backends leave it out), a call without arguments was stored with empty ones, and a reply cut off mid-body wasn't reported as Error::IncompleteResponse. send now runs the streaming request and returns the finished reply, so both methods behave the same. A long reply also no longer hits the per-read timeout before its first byte arrives.
  • read_file handles large files. It loaded the whole file into memory before the 128 KiB cap on tool results applied, so a multi-GB file could exhaust memory, and nothing past the first 128 KiB of a file could be read. It now streams the file and returns at most 100 KiB, stopping at a line boundary. When the file goes on, the text ends with a note like [lines 1–2400 of 90000 shown; call again with offset=2401 to read on]. The new offset (1-based start line) and limit (line count) arguments are passed on to an ACP client's fs/read_text_file as line and limit. A single line longer than the cap is shown cut, with a note saying so. edit_file, which has to hold the whole file, now refuses files over 10 MiB with an error giving the size.
  • Long-term memory keeps working when the embeddings provider fails. With [memory] embeddings configured, a network error, rate limit or bad key failed the remember, search_memory and edit_memory tool calls, and a failed remember lost the note. The back-fill of notes without vectors ran inside every call, so one note that failed to embed failed them all. Now an embeddings request is retried twice after a 429, 500, 502, 503 or 504 response, a timeout or a failed connection. If embedding still fails, remember and edit_memory save the note without a vector, to be embedded by a later back-fill, and search_memory falls back to keyword search and says so in its result, even when nothing matched. A back-fill failure is logged instead of failing the call. One call back-fills at most 256 notes, so enabling embeddings on a large store no longer stalls the first turn, and notes still waiting for a vector are found by keyword in the meantime. A note the provider refuses outright is skipped by the back-fill until restart instead of holding up the rest.
  • A slow or hung MCP server no longer stops openheim from starting. Servers were connected one after another with no time limit, so a server that never finished its handshake held up startup forever. They now start concurrently, and one that hasn't connected and listed its tools within 60 seconds is reported as failed in mcp_servers(), like any other connection failure.
  • "Allow Always" works for any tool that asks to be approved per exact arguments. ApprovalScope::ExactArguments only worked properly for execute_command: the remembered key was read from a command field. Any other tool using it (a custom tool, say) got a key of its raw argument text, so the same call with its keys in a different order or different spacing asked again. The key is now the tool's full arguments, compared as JSON with keys sorted. execute_command behaves as before, since command is its only argument.
  • delegate_task reports bad arguments as errors. Passing both agent and system_prompt, neither, or an unknown agent name returned the explanation as a successful result, so UIs showed the call as succeeded and the model had nothing marking it as a failure. These are now tool errors. An inline subagent's max_iterations is also capped at the session's own, since the model could previously pass any number.
  • Pasting several lines into the TUI no longer sends the first one. The TUI didn't turn on bracketed paste, so each newline in a paste arrived as an Enter keypress: the first line went out as the prompt and the rest landed in the input box. A paste now goes into the input at the cursor as one piece, and Enter sends the whole prompt with its line breaks and its first line's indentation (only blank lines around it are dropped). The one-line input bar shows each line break as ↵. Pastes are ignored while a popup or permission prompt is open. Terminals without bracketed paste behave as before.
  • A panic in the TUI prints a readable message. The terminal was only restored once the TUI returned, so a panic's message went to the alternate screen, with raw mode still on, and was lost. The panic hook now restores the terminal first. A panic in the TUI's agent task also closes the TUI, which returns an error, instead of leaving it on screen with nothing answering.
  • Saving a TUI theme can no longer corrupt config.toml. The [tui] section was taken to end at the next line starting with [, which includes a line such as [1, 2], inside a multi-line array, so theme_color could be written into the middle of the array. The section now ends only at a real table header. The edited file is also parsed before it's written: if it isn't the original config with only tui.theme_color changed, the file is left alone and the TUI reports that the theme has to be set by hand. save_theme_to_config_at now also fails if the config file isn't valid TOML.

Security

  • The /ws filesystem sidecar can no longer delete or move the whole workspace. Paths were only checked to be inside work_dir, and work_dir itself counts as inside, so {"action": "delete", "path": "."} recursively deleted the workspace and rename could move it away. delete and rename now refuse a path that names work_dir itself. They also act on a symlink rather than on what it points to: deleting a link to src/ used to recursively delete the real src/, and renaming a link moved its target. A symlink to work_dir is now just a link that can be deleted or renamed. Other entries inside work_dir work as before. tools::sandbox::validate_entry is the matching check for code that deletes or renames paths itself.
  • web_fetch refuses every non-public address. Its SSRF guard blocked loopback, private and link-local addresses but let several other non-public ranges through, some of which reach internal services. Now also refused: IPv4 0.0.0.0/8, 100.64.0.0/10 (shared address space, used by carrier-grade NAT and Tailscale), 192.0.0.0/24, 198.18.0.0/15 (benchmarking), 224.0.0.0/4 (multicast) and 240.0.0.0/4 (reserved); IPv6 multicast ff00::/8, documentation 2001:db8::/32, Teredo 2001::/32, local-use NAT64 64:ff9b:1::/48, site-local fec0::/10 and discard-only 100::/64. NAT64 (64:ff9b::/96) and 6to4 (2002::/16) addresses are checked by the IPv4 address they carry, so 64:ff9b::a9fe:a9fe and 2002:a9fe:a9fe::, which both reach the cloud metadata address 169.254.169.254, are refused. IPv6 literals in URLs (http://[2606:4700::1111]/) are now checked as addresses; they used to go to DNS with their brackets, where a public one failed to resolve.

Breaking changes (library)

What to change when upgrading an embedding from 0.13; the entries above have the details.

  • SessionHandle prompt and resume methods are replaced by prompt(impl Into<PromptInput>, cb) and OpenheimClient::resume_session, and SessionHandle::id is now id(). See the table under Changed.
  • run_agent_with_history and run_agent_streaming_with_history are replaced by core::agent::run_agent, which takes &dyn LlmClient / &dyn ToolExecutor.
  • PermissionGate::check takes one &PermissionRequest instead of three strings.
  • ClientIo::read_file takes a second parameter, lines: LineRange.
  • Struct literals need new fields: TurnContext { cwd, .. }, ContentBlock::ToolUse { signature, .. } (or use ContentBlock::tool_use), and ProviderConfig / AgentConfig { context_window, .. }.
  • Exhaustive matches need new arms: Error::IncompleteResponse, Error::ContextOverflow, StreamEvent::ContextTrimmed and ContentBlock::RedactedThinking.