OpenHands Analysis — OpenHands + Software Agent SDK
Phase 0 research document for KHAELOR. Repositories analyzed:
references/openhands— the OpenHands main repository (now Agent Canvas, a TypeScript UI)references/openhands-agent-sdk— the Software Agent SDK (Python), where the entire agent architecture now livesAll file paths below are relative to each repo root and prefixed
openhands/oragent-sdk/to disambiguate. This analysis is based on tracing actual implementation files, not READMEs.
0. The Most Important Finding: OpenHands Already Did the Split KHAELOR Wants
The single most significant architectural fact: OpenHands rebuilt itself around a clean SDK core, and the "OpenHands" repo is now just a client.
openhands/package.jsondeclares the main repo as@openhands/agent-canvas— a React 19 / React Router 7 / Electron web UI with@xterm/xterm,monaco-editor, and@openhands/typescript-clientas dependencies.openhands/src/containscomponents,routes,stores,hooks,i18n— a frontend, not an agent.- All intelligence, lifecycle, tools, workspace, events, and security live in
agent-sdk/, split into four packages:
| Package | Contents | Evidence |
|---|---|---|
openhands-sdk |
Agent, Conversation, Events, LLM, Context/Condenser, Security, Workspace interfaces | agent-sdk/openhands-sdk/openhands/sdk/ |
openhands-tools |
Terminal, FileEditor, grep, glob, browser, task tracker, delegate… | agent-sdk/openhands-tools/openhands/tools/ |
openhands-workspace |
Docker/remote workspace implementations | agent-sdk/openhands-workspace/openhands/workspace/ |
openhands-agent-server |
FastAPI server exposing conversations over REST/WebSocket | agent-sdk/openhands-agent-server/openhands/agent_server/ |
The mapping to the separation of concerns KHAELOR's CLAUDE.md prescribes is nearly one-to-one:
| Concern | OpenHands SDK concept | Key files |
|---|---|---|
| Intelligence | AgentBase / Agent |
agent-sdk/openhands-sdk/openhands/sdk/agent/base.py, agent/agent.py |
| Lifecycle | LocalConversation + ConversationState |
sdk/conversation/impl/local_conversation.py, sdk/conversation/state.py |
| Environment | BaseWorkspace / LocalWorkspace / RemoteWorkspace |
sdk/workspace/base.py, sdk/workspace/local.py, sdk/workspace/remote/base.py |
| Actions | ToolDefinition[Action, Observation] |
sdk/tool/tool.py, sdk/tool/schema.py, sdk/tool/registry.py |
| History | Event + EventLog |
sdk/event/base.py, sdk/conversation/event_store.py |
| Safety | SecurityAnalyzerBase + ConfirmationPolicyBase + SecurityRisk |
sdk/security/analyzer.py, sdk/security/confirmation_policy.py, sdk/security/risk.py |
A second critical observation: there is no first-class terminal UI anywhere. The SDK renders events via Rich visualize properties for debugging (sdk/conversation/visualizer/), and the product UI is a web app with an embedded xterm widget. The terminal-native niche KHAELOR targets is genuinely unoccupied by OpenHands.
1. The Agent Abstraction
1.1 Agents are stateless, frozen configuration
AgentBase (agent-sdk/openhands-sdk/openhands/sdk/agent/base.py) is a frozen Pydantic model (model_config = ConfigDict(frozen=True)): "Agents are stateless and should be fully defined by their configuration." Fields are llm, tools (a list of tool specs, not instances), mcp_config, agent_context, condenser, critic, system_prompt/system_prompt_filename, security_policy_filename, tool_concurrency_limit. All mutable runtime state lives in ConversationState. Materialized tools are private runtime attrs (_tools: dict[str, ToolDefinition]), resolved in _initialize() via a ThreadPoolExecutor and resolve_tool(tool_spec, state).
This is a strong design: the agent object can be serialized into the conversation state, persisted, and compared on resume.
Resume verification — AgentBase.verify(persisted) (base.py:670) enforces exactly two compatibility rules when resuming a conversation: the agent class must match, and tools may only be added, never removed ("Removing tools breaks backward compatibility because the LLM may have already been told about them. Adding new tools is safe"). LLM, condenser, and context can change freely between sessions. This is a precise, well-reasoned resume contract.
1.2 The step function — the real agent loop body
Agent.step() (agent/agent.py:637) is one LLM-round of the loop. Its actual sequence:
- Pending confirmations first:
ConversationState.get_unmatched_actions(state.active_branch())— if action events exist without matching observations, the user has implicitly confirmed them (by callingrun()again), so execute them and return. - Hook-blocked message check (
state.pop_blocked_message). - Message preparation:
prepare_llm_messages(state.view, condenser=self.condenser, llm=self.llm)— the condenser may return aCondensationinstead of messages, in which case the step just emits the condensation event and returns (the next step sees the condensed view). - LLM call:
make_llm_completion(...)with the full tool list. - Typed error handling around the LLM call (this is one of the most instructive parts):
FunctionCallValidationError→ emit a user-roleMessageEventcontaining the error text so the model corrects itself; the loop continues.LLMContentPolicyViolationError→ nudge message, continue.LLMMalformedConversationHistoryError→state.rebuild_view()+ emitCondensationRequest(condensation as recovery), else re-raise.LLMContextWindowExceedError→ emitCondensationRequestif a condenser can handle it; else a very detailed operator-facing warning (_log_context_window_exceeded_warning) and re-raise.
- Response classification:
classify_response(message)→TOOL_CALLS | CONTENT | REASONING_ONLY | EMPTY(agent/response_dispatch.py), each dispatched to a distinct handler.
1.3 From LLM response to actions
Agent._get_action_event() (agent.py:1189) converts each tool call into an ActionEvent:
- Parses/normalizes arguments (
parse_tool_call_arguments,normalize_tool_call,fix_malformed_tool_argumentsinagent/utils.py— tolerant of aliasing and common LLM malformations). - Unknown tool or validation failure →
_emit_tool_error(), which emits theActionEventand anAgentErrorEventwhose text goes back to the model ("Error validating tool 'X': ... Parameters provided: [keys]" — parameter names only, not values, keeping errors concise). - Pops two injected schema fields from the arguments:
security_risk(the model's self-assessed risk) andsummary(a one-line human-readable description of the action). Both are always added to every tool schema at completion time —make_llm_completion()(agent/utils.py:640) passesadd_security_risk_prediction=Trueunconditionally, and the docstring notes: "Summary field is always added to tool schemas for transparency and explainability of agent actions." - Instantiates the typed action:
tool.action_from_arguments(arguments)(Pydantic validation).
Execution goes through _ActionBatch (agent.py:185) — a frozen dataclass owning the batch lifecycle: _truncate_at_finish (discard tool calls after FinishTool), partition hook-blocked actions, execute the rest via ParallelToolExecutor (default tool_concurrency_limit=1, i.e. sequential), then emit() results in original order and finalize() (set ConversationExecutionStatus.FINISHED if FinishTool ran).
Tool executor exceptions of type ValueError are converted to AgentErrorEvent — again fed back to the model rather than surfaced to the user (_execute_action_event, agent.py:1334). The agent consumes its own recoverable errors — exactly the philosophy KHAELOR §17 demands.
1.4 Completion signal
Completion is an explicit FinishTool tool call (sdk/tool/builtins/), not inferred from a text response. A ThinkTool also exists for structured scratchpad thoughts. A single FinishAction/ThinkAction is exempt from confirmation (agent.py:1027). A CriticMixin can optionally evaluate finish actions and inject a follow-up user message instead of finishing (iterative refinement).
1.5 System prompt: static + dynamic split (prompt caching)
AgentBase.static_system_message vs AgentBase.dynamic_context (base.py:334, 498): the system message is assembled as two content blocks — a static, cacheable block (identity, behavior, tools, security policy; identical across conversations) and a dynamic block (datetime, repo skills, secrets metadata, working context) sent without a cache marker. Comment: "This content should NOT be included in the cached system prompt to enable cross-conversation cache sharing." A ~/.openhands/SOUL.md file can replace the default identity (base.py:60).
This split is directly applicable to KHAELOR's Anthropic prompt-caching strategy.
1.6 Stuck detection
StuckDetector (sdk/conversation/stuck_detector.py) scans a bounded window (20 events, MAX_EVENTS_TO_SCAN_FOR_STUCK_DETECTION) after the last user message for four patterns: repeated action→observation cycles, repeated action→error cycles, agent monologue (repeated messages with no user input), and alternating action/observation loops. Thresholds are configurable (StuckDetectionThresholds). On detection the run loop sets ConversationExecutionStatus.STUCK and stops; it also supports "nudging" (injecting a corrective message once per error event, tracked by _last_nudged_error_event_id). Deliberately windowed "to avoid materializing large file-backed event logs."
2. Conversation / Session Lifecycle
2.1 State machine
ConversationExecutionStatus (sdk/conversation/state.py:48): IDLE → RUNNING → (PAUSED | WAITING_FOR_CONFIRMATION | FINISHED | ERROR | STUCK | DELETING), with is_terminal() = {FINISHED, ERROR, STUCK}. Explicitly documented subtlety: IDLE is not terminal — it's the pre-run state.
2.2 The run loop
LocalConversation.run() (sdk/conversation/impl/local_conversation.py:1857) is the outer loop:
while True:
with state (FIFO lock):
if PAUSED or STUCK: break
if FINISHED: run Stop hooks (may veto and inject feedback → keep running); break
if stuck detected: mark STUCK / nudge; continue
if WAITING_FOR_CONFIRMATION: set RUNNING (user re-invoked run() = implicit approval)
agent.step(...)
if WAITING_FOR_CONFIRMATION: break # actions created, awaiting user
if budget exceeded: emit MaxBudgetReached error; break
if iteration >= max_iteration_per_run (500): emit MaxIterationsReached; breakNotable details:
- Concurrent user messages are never lost: the loop deliberately does not break immediately on FINISHED before re-checking —
send_message()(line 1761) resets FINISHED/STUCK → IDLE under the same FIFO lock, so a message arriving while the agent finishes gets processed on the next iteration (comment block at line 1952 documents this handshake precisely). - Errors wrap into
ConversationRunErrorcarrying the conversation id and persistence dir "for better UX", and aConversationErrorEventis appended unless the agent already surfaced a richer one. - A
CancellationToken(sdk/conversation/cancellation.py) threads through batches and tool execution for interruption;pause()acquires the state lock between steps. arun()/astep()mirror everything async, with_released_state_lock_during_io()releasing the state lock only for the network wait sosend_message()stays responsive — a whole class of lock gymnastics (FIFOLock,_step_holds_state_lock, issue #3485 workarounds) forced by Python's thread model.
2.3 Event log as source of truth — file-per-event
EventLog (sdk/conversation/event_store.py) is the persistence heart:
- One JSON file per event:
events/event-{idx}-{event_id}.jsonunder the conversation's persistence dir; append-only; written via aFileStoreabstraction (sdk/io/local.py,memory.py). - Cross-process safety: a lock file (
.eventlog.lock, 30 s timeout) plus re-sync from disk before append (_sync_from_disk) — multiple processes may append. - O(1)
len(); index built once by scanning filenames (_scan_and_build_index); events lazily loaded and cached by index; duplicate-ID and index-gap detection with warnings. ConversationState.append_event()(state.py:315) is "the single storage chokepoint: stamp parent_id, append, advance HEAD."
State beyond events (base_state.json, persistence_const.py) persists the serialized agent, execution status, stats, etc., with autosave on field mutation (_autosave_enabled, _dirty tracking). Resume = load base state, agent.verify(persisted_agent), rebuild the view from events, continue. init_state (agent.py:441) detects an existing SystemPromptEvent in the first events and skips re-initialization — replay-safe idempotent init.
2.4 The conversation is a tree, not a list
Events carry parent_id (sdk/event/base.py:33); ConversationState.leaf_event_id is a movable HEAD. path_to_root(leaf) yields the active branch; active_branch() excludes abandoned branches; fork and navigate_to(None) re-root. Legacy linear logs are handled by an "effective parent" fallback (event_store.py:91: no parent_id → previous index). This enables branch/rewind semantics (KHAELOR's future /branch, /rewind) directly on the event log — but it costs real complexity: head_is_empty sentinels, ROOT_PARENT_ID sentinels, artifact-event skipping (state.py:263 documents bug #4057), cycle detection.
2.5 The View: cached projection of the event log
View (sdk/context/view/view.py) is the derived, LLM-facing projection: View.from_events(...) folds Condensation events into the list and enforces properties (sdk/context/view/properties/) — invariants required by LLM APIs (e.g., tool_use/tool_result pairing). manipulation_indices computes the set of indices where the event list can be safely cut without violating any property — the condenser only cuts at these points. ConversationState.view (state.py:337) maintains this incrementally: a linear append replays only the tail (O(k)); a branch switch triggers a full rebuild (issue #3053). Events → messages conversion (LLMConvertibleEvent.events_to_messages, sdk/event/base.py:108) re-batches parallel tool calls sharing an llm_response_id back into a single assistant message and coalesces adjacent plain user messages.
Takeaway: raw events are the durable truth; the LLM view is a cached, invariant-enforced projection; compaction is itself an event. This is the cleanest triad in the entire codebase.
3. Workspace Abstraction
BaseWorkspace (sdk/workspace/base.py) is a Pydantic model with working_dir plus abstract execute_command, file_upload, file_download and git helpers (sdk/workspace/repo.py, sdk/git/* — git_changes.py, git_diff.py power the UI's diff panel). LocalWorkspace (sdk/workspace/local.py) implements them with subprocess/filesystem. RemoteWorkspace (sdk/workspace/remote/base.py) implements the same interface over HTTP against an agent-server.
The crucial subtlety: tools do not route their I/O through the workspace interface. FileEditor opens files directly (open(path, ...) in openhands-tools/openhands/tools/file_editor/editor.py); TerminalTool.create(conv_state, ...) merely reads conv_state.workspace.working_dir to spawn a local PTY (openhands-tools/openhands/tools/terminal/definition.py:317). Sandboxing is achieved not by proxying each file op but by moving the whole conversation into the sandbox: the agent-server runs inside the Docker container, tools run natively there, and the client talks to RemoteConversation/RemoteWorkspace over REST/WebSocket. The Workspace factory (sdk/workspace/workspace.py) dispatches Local vs Remote.
Trade-off analysis for KHAELOR: OpenHands' "relocate the agent" model gives native tool performance and zero per-op indirection, at the cost of a server, an API surface (~25 routers in agent-server/openhands/agent_server/: bash_router.py, file_router.py, event_router.py, conversation_router.py, desktop_router.py, …), and Docker plumbing. KHAELOR V1's planned in-process Workspace interface (readFile/writeFile/exec) is simpler and sufficient for local-only V1; the OpenHands evidence suggests keeping that interface thin so a future remote strategy can be "run KHAELOR's core remotely" rather than "proxy every syscall."
4. Tools
4.1 Tool protocol
ToolDefinition[ActionT, ObservationT] (sdk/tool/tool.py:347) — frozen Pydantic generic with:
action_type: type[Action]/observation_type: type[Observation]— schemas are Pydantic model classes (sdk/tool/schema.py), giving validation, JSON-schema export (OpenAI and MCP formats), and typed executor signatures.executor— runtime-only, excluded from serialization (SkipJsonSchema,exclude=True), so persisted state stores the tool definition, never live handles.- Auto snake_case naming from the class name (
__init_subclass__+_camel_to_snake:TerminalTool→terminal). ToolAnnotations(tool.py:215) — MCP-spec behavior hints:readOnlyHint,destructiveHint,idempotentHint,openWorldHint.readOnlyHintis used: read-only tools skip risk extraction entirely (agent.py:1058).DeclaredResources(tool.py:250) — tools declare resource keys for theParallelToolExecutorto lock (e.g., subprocess terminal declares("terminal:session",), pooled tmux declares none), so parallel tool execution serializes only actual conflicts.- Registration/resolution: tools are named specs (
Tool(name="TerminalTool")) resolved through a registry (sdk/tool/registry.py) against the conversation state at init — this is what keeps the Agent serializable.
Built-ins are minimal (sdk/tool/builtins/): FinishTool, ThinkTool (+ opt-in skill/vision tools). Real tools live in the separate openhands-tools package — a clean kernel/tools boundary.
4.2 The file editor (str_replace) — studied closely
FileEditor (openhands-tools/openhands/tools/file_editor/editor.py) descends from Anthropic's computer-use editor (credited at line 65). Commands: view, create, str_replace, insert, undo_edit.
str_replace (editor.py:178) matching/failure behavior:
- Reads the whole file, finds all occurrences with
re.escape(old_str)(literal matching), recording line numbers per match. - Zero matches → retries with
old_str.strip()(whitespace tolerance) — but deliberately does not stripnew_str("stripping it would silently drop meaningful leading/trailing whitespace… the caller asked to write"). - Still zero →
ToolError: "No replacement was performed, old_str…did not appear verbatim in {path}." - More than one →
"Multiple occurrences of old_str…in lines [n1, n2]. Please ensure it is unique."— the line numbers are what makes this error actionable for the model. - Success → writes, saves the previous content into
FileHistoryManager(10 entries per file, enablingundo_edit), and returns a snippet of the edited region (±SNIPPET_CONTEXT_WINDOWlines) plus "Review the changes and make sure they are as expected. Edit the file again if necessary." — the model verifies its own edit without a follow-up read.
Supporting quality work: validate_path (editor.py:626) rejects relative paths with a suggestion — "The path should be an absolute path. Maybe you meant {cwd/path}?" — and gives distinct errors for create-on-existing, missing paths, and directory misuse; EncodingManager auto-detects encodings (utils/encoding.py); binary detection via binaryornot; images returned as base64 ImageContent; directory view lists 2 levels with hidden-file counts; file size guard (10 MB); observations carry old_content/new_content so downstream UIs can render diffs.
4.3 The terminal tool
TerminalTool (openhands-tools/openhands/tools/terminal/definition.py, impl.py, terminal/ backends) is a persistent stateful session, not one-shot exec:
- Backends auto-detected: tmux, plain subprocess PTY, or PowerShell (
create(..., terminal_type=...)). TerminalAction:command,is_input(send keystrokes to the running process —C-c, arrows,ENTER…),timeout,reset.- Soft timeout model: with no explicit timeout, a command that produces no new output for
NO_CHANGE_TIMEOUT_SECONDSreturns early withexit_code = -1("process still running"); the model can then send input, wait (empty command), orC-c. This lets one blocking-style tool approximate background processes — but it is a convention the model must learn, encoded in long schema descriptions. TerminalObservation.to_llm_contentappends PS1-harvested metadata:[Current working directory: …],[Python interpreter: …],[Command finished with exit code N](metadata.pyparses a custom PS1 block).- Output truncation:
maybe_truncate(sdk/utils/truncate.py:50) keeps head and tail (middle-out truncation), and optionally saves the full output to a file (save_dir,tool_prefix="terminal") referencing it in the notice — the model can go read the full log. Same utility used by the editor and browser tools.
KHAELOR's decision to split bash (short-lived) from a real process manager (start/list/read/write/stop) is stronger than OpenHands' single soft-timeout terminal; but the metadata suffix, PTY-backed session persistence, is_input keystroke channel, and save-full-output-to-file patterns are all worth adopting.
4.4 grep / glob
openhands-tools/openhands/tools/grep/impl.py prefers ripgrep and falls back to system grep with an explicit logged warning (_check_ripgrep_available, _log_ripgrep_fallback_warning); glob/ mirrors this. Results are bounded, structured observations — consistent with KHAELOR §10's "never dump thousands of lines into model context."
5. Event System
Event(sdk/event/base.py:20): frozen Pydantic,extra="forbid",id(uuid),timestamp,source: SourceType(agent | user | environment),parent_id. Polymorphic serialization viaDiscriminatedUnionMixin(sdk/utils/models.py) — akinddiscriminator letsEvent.model_validate_jsonreconstruct the right subclass from disk. (In TypeScript this is a native discriminated union — none of this machinery is needed.)LLMConvertibleEventaddsto_llm_message(); concrete types live insdk/event/llm_convertible/:SystemPromptEvent(embeds the system prompt and the tool list — the prompt actually sent is part of history),MessageEvent,ActionEvent(tool_call, typedaction,thought,thinking_blocks,security_risk,summary,llm_response_id),ObservationEvent,AgentErrorEvent,UserRejectObservation.- Non-LLM events:
Condensation/CondensationRequest(event/condenser.py),PauseEvent,InterruptEvent,ConversationErrorEvent,ConversationStateUpdateEvent(server→client state sync artifact, explicitly not a tree node — state.py:326),TokenEvent(vLLM token ids), hook events. - State reconstruction: everything the LLM sees is derived from events (
View.from_events), and everything the UI shows is derived from events (the agent-server streams them over WebSocket viaevent_router.py/event_service.py; the TS client inopenhands/srcconsumes them). Replay/resume is therefore trivial-by-construction. - Wart: every event and observation also carries a
visualize -> rich.Textproperty (base.py:52, terminal definition.py:204 colors error lines red, adds emoji). Presentation is welded into domain objects — convenient for their CLI visualizer, wrong for a system with a real UI layer. KHAELOR should keep rendering strictly out of event types.
Streaming deltas: a streaming_delta.py event exists and on_token: TokenCallbackType callbacks (raw litellm ModelResponseStream chunks, sdk/llm/streaming.py) thread from LLM.completion through agent.step to the conversation — but streaming is optional and degraded gracefully to off ("Streaming requested without an on_token callback; falling back to a non-streaming completion", llm.py:1500). The architecture is fundamentally request/response with streaming bolted on; events are appended only when complete.
6. Security: Analyzer + Policy + Confirmation
The design cleanly separates risk assessment from confirmation decision:
SecurityRisk(sdk/security/risk.py):UNKNOWN | LOW | MEDIUM | HIGH, with deliberate semantics — UNKNOWN is incomparable (comparisons raiseValueError) rather than silently lowest; careful__gt__overrides becausestrmixin MRO would otherwise give alphabetical ordering (documented at line 126).SecurityAnalyzerBase(sdk/security/analyzer.py):security_risk(action) -> SecurityRisk; analysis errors default to HIGH ("Default to HIGH risk on analysis error for safety", line 108). The defaultLLMSecurityAnalyzer(sdk/security/llm_analyzer.py) simply returns the model's ownsecurity_riskargument — the schema-injected self-assessment. Heavier analyzers exist (llm_analyzerensembles,grayswan/,toolshield_llm_analyzer.py, a shell AST parser_shell_ast.py/shell_parser.pyfor command-level analysis).ConfirmationPolicyBase(sdk/security/confirmation_policy.py):AlwaysConfirm,NeverConfirm,ConfirmRisky(threshold=HIGH, confirm_unknown=True)— pure functions from risk to bool, stored onConversationState.- Flow (
Agent._requires_user_confirmation, agent.py:1015): after actions are created (and persisted as events) but before execution, analyze all → if any risk triggers the policy, setWAITING_FOR_CONFIRMATIONand stop the run loop. Approval = callingrun()again (pending unmatched actions execute first thing in the next step); rejection =reject_pending_actions()which appendsUserRejectObservationevents (the model sees why).FinishAction/ThinkActionnever require confirmation;readOnlyHinttools bypass risk extraction.
Honest assessment: LLM self-assessed risk is cheap and surprisingly usable, but the model grades its own homework — a system prompt section (security_policy in the prompt registry, base.py:223) instructs it how. KHAELOR's capability-based policy (file.write.project: allow, process.execute: ask…) is deterministic and should remain primary; an LLM-self-assessment field is a reasonable supplementary signal for the ask tier, and the "unmatched action events = pending approval" persistence trick is directly reusable (approvals survive restarts for free).
7. LLM Layer
LLM (sdk/llm/llm.py, ~2,300 lines) is a Pydantic model wrapping litellm for any-provider support:
- Two API styles:
completion()(Chat Completions) andresponses()(OpenAI Responses API), sync + async variants — four near-duplicate code paths. - Retries: a tenacity-based
RetryMixin(sdk/llm/mixins/) with exponential backoff, retry listeners, and typed exception mapping (sdk/llm/exceptions/—LLMContextWindowExceedError,LLMMalformedConversationHistoryError,LLMContentPolicyViolationError, etc.) that the agent loop branches on. Mapping provider errors into a typed taxonomy the kernel can react to is the key idea. - Prompt caching: cache markers on the static system block; a dedicated fallback when the provider rejects too-small cache blocks (
is_prompt_cache_too_small→ retry withcaching_prompt=False, llm.py:1552). - Telemetry / cost (
sdk/llm/utils/telemetry.py,metrics.py):Telemetry.on_responserecords real usage — prompt/completion tokens,cache_read_tokens/cache_write_tokens, latency, per-callCostcomputed from litellm pricing data, accumulated intoMetrics(with a documentedcache_hit_ratesubtlety for providers that report cache reads separately). Snapshots ride onLLMResponseand aggregate intoConversationStats(sdk/conversation/conversation_stats.py) — per-conversation cost is real data, never estimated. This satisfies KHAELOR Absolute Rule #4 by construction. - Non-native tool calling:
NonNativeToolCallingMixinfakes function calling via prompting for weak models;fix_malformed_tool_argumentsrepairs common mistakes. - Extras KHAELOR doesn't need:
RouterLLM, fallback strategies (fallback_strategy.py),LLMRegistry, profile stores, subscription auth, OpenRouter/AWS header plumbing, vLLM token-id events.
For Anthropic-only KHAELOR, the lessons are: (1) typed error taxonomy consumed by the kernel, (2) real usage accounting incl. cache tokens, (3) static/dynamic prompt split for caching, (4) retry with backoff at the model layer. The 2,300-line universal adapter is precisely what ModelClient must not become.
8. Context Management: Condensers
CondenserBase (sdk/context/condenser/base.py): condense(view) -> View | Condensation. RollingCondenser adds condensation_requirement(view) -> HARD | SOFT | None and get_condensation(view), with graceful degradation: SOFT requirement + no condensation available → return the uncondensed view; HARD (agent literally cannot proceed) → hard_context_reset() last resort.
LLMSummarizingCondenser (llm_summarizing_condenser.py): triggers when the view exceeds max_size (default 240 events) or on an explicit CondensationRequest; keeps the first keep_first (default 2) events (system prompt + first user message), summarizes a middle range with a dedicated LLM call, and keeps a recent suffix — cutting only at view.manipulation_indices so tool_use/tool_result pairs are never split.
The structural insight worth stealing wholesale: a condensation is an event in the log (Condensation carries the summary + the forgotten range). The raw events remain on disk; the View projection applies condensations deterministically on every rebuild; /context-style inspection and un-condensation are possible by construction; and condensation doubles as the recovery path for context-window and malformed-history errors. Two triggers (proactive size threshold + reactive CondensationRequest from exceptions) give defense in depth.
Gap vs KHAELOR's plan: OpenHands measures pressure in event count, not tokens (token counting exists in sdk/llm/utils/ but isn't the default trigger), and its summary is one LLM-written blob rather than KHAELOR's structured checkpoint (objective / completed / failed_attempts / running_processes / next_steps). KHAELOR should keep its structured checkpoint schema and token-based budgeting, layered on OpenHands' event-sourced compaction mechanics.
9. Framework Complexity KHAELOR Should Not Inherit
Traced concretely, the weight is real:
LocalConversation.__init__(local_conversation.py:200) takes ~28 parameters and its module imports plugins, skills, marketplaces, subagents, MCP clients, hooks, credential binding, secret ciphers, ACP agents, observability, and title generation — the file is ~2,800 lines. The lifecycle object became a god object even though the concepts around it are clean.AgentBaseis polluted by integrations: MCP config, MCP dynamic tool reconciliation (_on_mcp_tools_changed,_on_mcp_tools_reconciled— ~110 lines of lock-guarded races in base.py:904–1012), ACP capability flags (supports_openhands_tools,agent_kind), skills auto-attachment, vision fallback tools. The "small kernel" rule exists precisely to prevent this accretion.Agent.step()itself carries vision-model fallbacks, vLLM token events, critic evaluation, hook-blocked bookkeeping, and observability decorators inline — the minimal loop is buried.- Agent-server: FastAPI app with ~25 routers + services (
bash_service.py,desktop_service.py, VSCode/desktop integration, sockets,docker/build tooling,agent-server.specPyInstaller packaging). Necessary for their cloud product; pure liability for a terminal-native tool. - Threading/locking:
FIFOLock,_released_state_lock_during_io,_step_holds_state_lock, per-issue workarounds (#3485, #3053, #4057) — much of this evaporates in a single-threaded TS event loop with structured async. - Pydantic discriminated-union machinery (
DiscriminatedUnionMixin,kind_of, subclass registries) — TypeScript unions + atypefield give this for free. - Microagents/skills/plugins/marketplace/profiles/critics/hooks — six extension systems interleaved with the core. KHAELOR V1 needs zero of them.
Verdict on the CLAUDE.md §3.3 question — can KHAELOR adopt the principles without the framework weight? Yes, demonstrably: the principles (stateless agent config, event-sourced state, view projection, condensation-as-event, risk/policy split, typed tool protocol) are all expressible in a few small modules; the weight comes from multi-provider support, remote orchestration, and extension systems that KHAELOR V1 explicitly excludes.
WHAT OPENHANDS DOES VERY WELL
- Event log as the single source of truth. Append-only file-per-event JSON (
event_store.py), O(1) length, lazy load, cross-process locking; conversation state, LLM context, and UI are all projections of it. Resume and replay are trivial-by-construction, and idempotentinit_statemakes restarts safe. - Condensation as an event. Compaction lives in the history; the
Viewre-applies it deterministically, cuts only at API-safemanipulation_indices, and doubles as the recovery path for context-window and malformed-history errors (SOFT/HARD requirement model). - Stateless, frozen agent configuration + a precise resume contract.
AgentBase.verify: same class, tools add-only; everything else swappable between sessions. - Typed error taxonomy consumed by the loop. Provider chaos is mapped to
LLMContextWindowExceedError/LLMMalformedConversationHistoryError/ etc., and each has a distinct, sensible kernel reaction; recoverable tool errors go back to the model as concise events (parameter names, not values), not to the user. - The str_replace editor's failure UX. Literal matching with strip-retry, multiple-occurrence errors that cite line numbers, "Maybe you meant {abs path}?", post-edit snippet for self-verification, per-file undo history, encoding detection.
- Risk/policy separation with persistence-native confirmation. Analyzer produces
SecurityRisk(UNKNOWN incomparable, errors default HIGH); policy decides; pending approval = unmatched ActionEvents in the log, so approvals survive restarts;readOnlyHintshort-circuits; rejections become observations the model learns from. - Prompt-caching-aware system prompt architecture. Static cacheable block + dynamic uncached block, with a fallback when cache minimums aren't met.
- Honest accounting. Cost/token metrics (incl. cache read/write) come exclusively from real API usage metadata, snapshotted per response, aggregated per conversation.
- Output truncation done right. Middle-out head+tail truncation with the full output saved to disk and referenced, so nothing is irrecoverably lost.
- They proved the SDK-core split. The flagship product is now a thin TS client over the SDK — validation that a clean kernel supports any front end.
WHAT OPENHANDS DOES POORLY
- Streaming is an afterthought.
on_tokencallbacks pass raw litellm chunks, silently degrade to non-streaming, and events only exist post-completion. The architecture is request/response at heart — unacceptable for a terminal UI where streaming is the product. - The kernel is not small.
Agent.stepandLocalConversationabsorbed MCP reconciliation, plugins, skills, hooks, critics, ACP branching, vision fallbacks, and observability;LocalConversation.__init__has ~28 parameters. The exact god-object failure KHAELOR's Absolute Rule #3 guards against. - Presentation welded into domain objects. Every event/observation carries a Rich
visualizeproperty (emoji, color heuristics) — rendering policy trapped in the data layer. - No terminal product. The interactive surfaces are a web app and a debug visualizer; nothing here informs TUI excellence.
- A 2,300-line universal LLM adapter. litellm + two API styles × sync/async = four near-duplicate call paths, provider header plumbing, routers, registries — the cost of multi-provider generality.
- Concurrency by locks and workarounds. FIFOLock re-entrancy tricks, releasing locks mid-await, flags like
_step_holds_state_lock, multiple issue-numbered patches — accidental complexity from Python threads. - Event-count context pressure. Condensation triggers on number of events (
max_size=240), not token budget; token pressure is handled reactively via exceptions. - Default security analyzer is self-assessment. The model rates the risk of its own actions; deterministic analysis exists but is opt-in.
- Background work via soft-timeout convention. One terminal with
exit_code=-1/is_inputsemantics the model must learn from prose descriptions, instead of an explicit process manager. - Tree-of-events edge-case debt. Legacy parent fallbacks,
ROOT_PARENT_ID/head_is_emptysentinels, artifact-event skipping — powerful branching paid for with subtle invariants (#4057).
WHAT KHAELOR SHOULD ADOPT
- Event-sourced sessions: append-only per-event JSON files as the source of truth; typed events with
id/timestamp/source; session resume = replay; idempotent init (skip if system-prompt event exists). Map directly onto KHAELOR's typed event bus (§7–8). - View-as-projection + compaction-as-event: a cached, incrementally-updated LLM view derived from events;
ContextCompactedas a persisted event; only cut at boundaries that preserve Anthropic tool_use/tool_result pairing; dual trigger (proactive budget + reactive context-window error recovery). Keep KHAELOR's structured checkpoint YAML as the summary payload and use token-based budgets. - Stateless agent/kernel config + the resume contract (tools add-only; model/config freely swappable).
- The str_replace editor playbook for KHAELOR's
edittool: literal match, whitespace strip-retry on match only, unique-occurrence enforcement with line numbers in the error, absolute-path suggestion, post-edit snippet/diff, per-file undo history, atomic writes. - Risk/policy separation grafted onto capabilities: KHAELOR's deterministic capability evaluator (
file.write,process.execute) as the analyzer;allow/ask/denyas the policy; pending-approval represented as persisted unexecuted action events; UNKNOWN treated as unsafe; analyzer failure ⇒ ask; read-only capability short-circuit; rejection reasons fed back to the model as observations. - Typed model-error taxonomy in the Anthropic client (overloaded/rate-limit/context-exceeded/invalid-request) with distinct kernel reactions; agent self-consumes recoverable tool errors with concise, name-only messages.
- Static/dynamic system prompt split for Anthropic prompt caching, and usage accounting from real API metadata only (input/output/cache-read/cache-write, per-session accumulation).
- Middle-out truncation + full-output spill files for tool observations; terminal observations annotated with cwd/exit-code metadata.
- Tool protocol shape: typed Action/Observation schemas per tool, runtime executor excluded from serialization, behavior annotations (read-only/destructive), auto-derived names,
summaryargument on tool schemas for one-line action descriptions the TUI can display in collapsed tool rows. - ripgrep-first search with explicit fallback, bounded structured results.
- Windowed stuck detection (repeat action/observation, repeat errors, monologue) as a cheap kernel-adjacent service — inform the user rather than silently looping.
WHAT KHAELOR SHOULD NOT COPY
- The universal LLM adapter (litellm, routers, fallback registries, dual API styles, non-native tool-calling mocks). KHAELOR is Anthropic-only behind one small
ModelClient. - Bolted-on streaming. Invert it: KHAELOR's kernel emits
TextDelta/ToolInputDelta/ThinkingDeltaevents natively; non-streaming is the degenerate case, never the default. - The agent-server / Docker / remote-workspace stack — FastAPI routers, WebSockets, PyInstaller specs, sandbox images. V1 is a local process; keep only a thin
Workspaceinterface so remoting stays possible. - Extension systems in the kernel: MCP reconciliation, skills, plugins, marketplace, profiles, critics, hooks, subagent registries inside Agent/Conversation. Design event-bus seams for them; implement none in V1.
visualizeon domain objects. Rendering lives insrc/tui/tool-view/, keyed by event type — never on the event.- Lock-based concurrency gymnastics. Use the single-threaded event loop, one session-serialized command queue, and
AbortController-style cancellation instead of FIFO re-entrant locks released mid-await. - The full event-tree in V1. Keep
parent_idin the event schema (cheap future-proofing for/branch//rewind), but ship a linear log; OpenHands shows branching works and shows its sentinel/legacy-fallback tax. - Event-count-triggered compaction — budget in tokens against the real context window.
- Soft-timeout terminal as the only background story — KHAELOR's explicit
processmanager (start/list/read/write/stop) is the better design; keep the PTY session + metadata ideas, drop the-1exit-code convention as the primary mechanism. - LLM self-assessed risk as the default gate — deterministic capability rules first; self-assessment at most as a supplementary signal.
- Discriminated-union serialization frameworks — use native TypeScript tagged unions with a
typefield and a schema-validated (e.g. zod) decode at the persistence boundary.
Author: Simon-Pierre Boucher · contact@spboucher.ai