Repository navigation
v0.14.0
Added
- The TUI can cancel a running turn. Press
Ctrl+Cwhile 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, pressCtrl+Ctwice within two seconds (the footer shows "Ctrl+C again to quit" after the first press).:qstill quits at once. context_windowfor providers,OpenheimBuilder::context_windowandAgentConfig::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 buildsProviderConfigorAgentConfigwith a struct literal needscontext_window: None.read_filetakesoffsetandlimitto 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 onErrorexhaustively needs the new arm.StreamEvent::ContextTrimmed { dropped }, sent when the next request leaves out thedroppedoldest history messages to fit the context window. The TUI shows it as a notice; ACP sends nothing. Code that matches onStreamEventexhaustively 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 onErrorexhaustively needs the new arm.Message::transcript()lists what a UI shows for a saved message, in order, asTranscriptEntryvalues (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 fromresume_sessionin your own UI.ContentBlock::ToolUsehas asignaturefield for an opaque token the provider needs back with the call (Gemini'sthoughtSignature). It's optional and left out of saved history when unset, so existing history loads as before. Code that buildsToolUsewith a struct literal needssignature: None, or can use the newContentBlock::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 onContentBlockexhaustively needs the new arm.LongTermMemory::search_detailedreturns aMemorySearch: the hits, plus whether the query couldn't be embedded (semantic_failed) and how many notes have no vector yet (unindexed).searchstill returns just the hits.VectorStore::unembedded_up_to(limit),unembedded_count()andsearch_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::prompttakes anything that converts into the newPromptInput(a&str/String, orPromptInput::text(..).image(data, mime_type)), always hands your callbackStreamEvents, and always returns the turn'sStopReason. ACP vocabulary is an opt-in adapter (featureacp) instead of parallel methods.SessionHandle::idis now private; read it withid().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))(returnsStopReason)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?;thensession.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 handlesession.idsession.id()load_session/restoreused to pass a fallback warning to the callback as an agent message. It's now only inLoadedSession::warning, so show it yourself. -
The agent loop has one entry point,
core::agent::run_agent. It replacesrun_agent_with_history(pass|_| {}as the new last argument) andrun_agent_streaming_with_history(same arguments). It takes the LLM client and tool executor as&dyn LlmClient/&dyn ToolExecutorinstead ofArcs by value; with anArc, pass&*llm. -
Permission prompts say when a subagent is asking. A
delegate_tasksubagent'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::checknow takes one&PermissionRequest(tool_call_id,tool_name,arguments, andsubagent, which is the subagent's name, orNonefor 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, changecheck(&self, id: &str, tool_name: &str, arguments: &str)tocheck(&self, request: &PermissionRequest<'_>)and read the fields fromrequest.PermissionRequestis#[non_exhaustive]; build one withPermissionRequest::new(id, tool_name, arguments). -
ClientIo::read_filetakes a line range:read_file(&self, path: &Path, lines: LineRange).LineRange(incore::client_io) has optionalline(1-based) andlimitfields;LineRange::default()is the whole file.read_filepasses on the range the model asked for, andedit_fileasks for the whole file. To update your implementation, add the parameter; returning the whole file regardless still works, sinceread_filecaps what it returns. -
Tools work in the session's
cwd. A session'scwd(ACPsession/newandsession/load,SessionBuilder::cwd, or the directory openheim was started from) was only stored forsession/listfiltering, and every session's tools worked fromwork_dir. Now, whencwdis a directory insidework_dir, relative paths resolve there andexecute_commandruns there, the way a shell started in that directory would.work_diris still the boundary for every path, and acwdoutside it (such as a client's own local path) is ignored in favour ofwork_dir. For embedders:TurnContexthas a newcwdfield, so code that builds one with a struct literal needscwd: ...(use the work directory to keep the old behaviour). File-touching custom tools should resolve paths with the newTurnContext::resolve_pathinstead ofsandbox::validate_path(path, turn.work_dir), which still works but resolves relative paths fromwork_dir.sandbox::validate_path_fromis the same check with an explicitcwd. -
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_windowset 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 withError::ContextOverflowsaying 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 aToolResultevent, 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_tokenspartway through a tool call (a largewrite_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 ACPsession/new) now fails withError::NotFoundfor 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'sfinishReason) fails withError::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 athought_signature"). Second, the call's id on itsfunctionResponse: 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_thinkingblocks 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_tokensworks with OpenAI's reasoning models. It was sent asmax_tokens, which OpenAI has replaced withmax_completion_tokensand which its o-series and GPT-5 models reject, so anopenaiprovider withmax_tokensset failed on them.openai-kind providers now sendmax_completion_tokens.openai_compatibleones still sendmax_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::sendandOpenAiCompatibleClient::sendnow 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 asError::IncompleteResponse.sendnow 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_filehandles 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 newoffset(1-based start line) andlimit(line count) arguments are passed on to an ACP client'sfs/read_text_fileaslineandlimit. 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 theremember,search_memoryandedit_memorytool calls, and a failedrememberlost 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,rememberandedit_memorysave the note without a vector, to be embedded by a later back-fill, andsearch_memoryfalls 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::ExactArgumentsonly worked properly forexecute_command: the remembered key was read from acommandfield. 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_commandbehaves as before, sincecommandis its only argument. delegate_taskreports bad arguments as errors. Passing bothagentandsystem_prompt, neither, or an unknownagentname 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'smax_iterationsis 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, sotheme_colorcould 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 onlytui.theme_colorchanged, the file is left alone and the TUI reports that the theme has to be set by hand.save_theme_to_config_atnow also fails if the config file isn't valid TOML.
Security
- The
/wsfilesystem sidecar can no longer delete or move the whole workspace. Paths were only checked to be insidework_dir, andwork_diritself counts as inside, so{"action": "delete", "path": "."}recursively deleted the workspace andrenamecould move it away.deleteandrenamenow refuse a path that nameswork_diritself. They also act on a symlink rather than on what it points to: deleting a link tosrc/used to recursively delete the realsrc/, and renaming a link moved its target. A symlink towork_diris now just a link that can be deleted or renamed. Other entries insidework_dirwork as before.tools::sandbox::validate_entryis the matching check for code that deletes or renames paths itself. web_fetchrefuses 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: IPv40.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) and240.0.0.0/4(reserved); IPv6 multicastff00::/8, documentation2001:db8::/32, Teredo2001::/32, local-use NAT6464:ff9b:1::/48, site-localfec0::/10and discard-only100::/64. NAT64 (64:ff9b::/96) and 6to4 (2002::/16) addresses are checked by the IPv4 address they carry, so64:ff9b::a9fe:a9feand2002:a9fe:a9fe::, which both reach the cloud metadata address169.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.
SessionHandleprompt and resume methods are replaced byprompt(impl Into<PromptInput>, cb)andOpenheimClient::resume_session, andSessionHandle::idis nowid(). See the table under Changed.run_agent_with_historyandrun_agent_streaming_with_historyare replaced bycore::agent::run_agent, which takes&dyn LlmClient/&dyn ToolExecutor.PermissionGate::checktakes one&PermissionRequestinstead of three strings.ClientIo::read_filetakes a second parameter,lines: LineRange.- Struct literals need new fields:
TurnContext { cwd, .. },ContentBlock::ToolUse { signature, .. }(or useContentBlock::tool_use), andProviderConfig/AgentConfig { context_window, .. }. - Exhaustive matches need new arms:
Error::IncompleteResponse,Error::ContextOverflow,StreamEvent::ContextTrimmedandContentBlock::RedactedThinking.