# 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 lives > > All file paths below are relative to each repo root and prefixed `openhands/` or `agent-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.json` declares the main repo as `@openhands/agent-canvas` — a React 19 / React Router 7 / Electron web UI with `@xterm/xterm`, `monaco-editor`, and `@openhands/typescript-client` as dependencies. `openhands/src/` contains `components`, `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: 1. **Pending confirmations first**: `ConversationState.get_unmatched_actions(state.active_branch())` — if action events exist without matching observations, the user has implicitly confirmed them (by calling `run()` again), so execute them and return. 2. **Hook-blocked message check** (`state.pop_blocked_message`). 3. **Message preparation**: `prepare_llm_messages(state.view, condenser=self.condenser, llm=self.llm)` — the condenser may return a `Condensation` *instead of* messages, in which case the step just emits the condensation event and returns (the next step sees the condensed view). 4. **LLM call**: `make_llm_completion(...)` with the full tool list. 5. **Typed error handling around the LLM call** (this is one of the most instructive parts): - `FunctionCallValidationError` → emit a user-role `MessageEvent` containing the error text so the *model* corrects itself; the loop continues. - `LLMContentPolicyViolationError` → nudge message, continue. - `LLMMalformedConversationHistoryError` → `state.rebuild_view()` + emit `CondensationRequest` (condensation as *recovery*), else re-raise. - `LLMContextWindowExceedError` → emit `CondensationRequest` if a condenser can handle it; else a very detailed operator-facing warning (`_log_context_window_exceeded_warning`) and re-raise. 6. **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_arguments` in `agent/utils.py` — tolerant of aliasing and common LLM malformations). - Unknown tool or validation failure → `_emit_tool_error()`, which emits the `ActionEvent` *and* an `AgentErrorEvent` whose 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) and `summary` (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`) passes `add_security_risk_prediction=True` unconditionally, 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; break ``` Notable 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 `ConversationRunError` carrying the conversation id and persistence dir "for better UX", and a `ConversationErrorEvent` is 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 so `send_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}.json` under the conversation's persistence dir; append-only; written via a `FileStore` abstraction (`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`. `readOnlyHint` is *used*: read-only tools skip risk extraction entirely (agent.py:1058). - `DeclaredResources` (tool.py:250) — tools declare resource keys for the `ParallelToolExecutor` to 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: 1. Reads the whole file, finds all occurrences with `re.escape(old_str)` (literal matching), recording **line numbers** per match. 2. Zero matches → retries with `old_str.strip()` (whitespace tolerance) — but deliberately does **not** strip `new_str` ("stripping it would silently drop meaningful leading/trailing whitespace… the caller asked to write"). 3. Still zero → `ToolError: "No replacement was performed, old_str `…` did not appear verbatim in {path}."` 4. 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. 5. Success → writes, saves the previous content into `FileHistoryManager` (10 entries per file, enabling `undo_edit`), and returns a **snippet** of the edited region (± `SNIPPET_CONTEXT_WINDOW` lines) 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_SECONDS` returns early with `exit_code = -1` ("process still running"); the model can then send input, wait (empty command), or `C-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_content` appends PS1-harvested metadata: `[Current working directory: …]`, `[Python interpreter: …]`, `[Command finished with exit code N]` (`metadata.py` parses 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 via `DiscriminatedUnionMixin` (`sdk/utils/models.py`) — a `kind` discriminator lets `Event.model_validate_json` reconstruct the right subclass from disk. (In TypeScript this is a native discriminated union — none of this machinery is needed.) - `LLMConvertibleEvent` adds `to_llm_message()`; concrete types live in `sdk/event/llm_convertible/`: `SystemPromptEvent` (embeds the system prompt *and* the tool list — the prompt actually sent is part of history), `MessageEvent`, `ActionEvent` (tool_call, typed `action`, `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 via `event_router.py` / `event_service.py`; the TS client in `openhands/src` consumes them). Replay/resume is therefore trivial-by-construction. - Wart: every event and observation also carries a `visualize -> rich.Text` property (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**: 1. **`SecurityRisk`** (`sdk/security/risk.py`): `UNKNOWN | LOW | MEDIUM | HIGH`, with deliberate semantics — UNKNOWN is *incomparable* (comparisons raise `ValueError`) rather than silently lowest; careful `__gt__` overrides because `str` mixin MRO would otherwise give alphabetical ordering (documented at line 126). 2. **`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 default `LLMSecurityAnalyzer` (`sdk/security/llm_analyzer.py`) simply returns the model's own `security_risk` argument — the schema-injected self-assessment. Heavier analyzers exist (`llm_analyzer` ensembles, `grayswan/`, `toolshield_llm_analyzer.py`, a shell AST parser `_shell_ast.py`/`shell_parser.py` for command-level analysis). 3. **`ConfirmationPolicyBase`** (`sdk/security/confirmation_policy.py`): `AlwaysConfirm`, `NeverConfirm`, `ConfirmRisky(threshold=HIGH, confirm_unknown=True)` — pure functions from risk to bool, stored on `ConversationState`. 4. **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, set `WAITING_FOR_CONFIRMATION` and stop the run loop. Approval = calling `run()` again (pending unmatched actions execute first thing in the next step); rejection = `reject_pending_actions()` which appends `UserRejectObservation` events (the model sees *why*). `FinishAction`/`ThinkAction` never require confirmation; `readOnlyHint` tools 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) and `responses()` (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 with `caching_prompt=False`, llm.py:1552). - **Telemetry / cost** (`sdk/llm/utils/telemetry.py`, `metrics.py`): `Telemetry.on_response` records real usage — prompt/completion tokens, **`cache_read_tokens` / `cache_write_tokens`**, latency, per-call `Cost` computed from litellm pricing data, accumulated into `Metrics` (with a documented `cache_hit_rate` subtlety for providers that report cache reads separately). Snapshots ride on `LLMResponse` and aggregate into `ConversationStats` (`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**: `NonNativeToolCallingMixin` fakes function calling via prompting for weak models; `fix_malformed_tool_arguments` repairs 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. - **`AgentBase` is 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.spec` PyInstaller 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 + a `type` field 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 1. **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 idempotent `init_state` makes restarts safe. 2. **Condensation as an event.** Compaction lives *in* the history; the `View` re-applies it deterministically, cuts only at API-safe `manipulation_indices`, and doubles as the recovery path for context-window and malformed-history errors (SOFT/HARD requirement model). 3. **Stateless, frozen agent configuration + a precise resume contract.** `AgentBase.verify`: same class, tools add-only; everything else swappable between sessions. 4. **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. 5. **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. 6. **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; `readOnlyHint` short-circuits; rejections become observations the model learns from. 7. **Prompt-caching-aware system prompt architecture.** Static cacheable block + dynamic uncached block, with a fallback when cache minimums aren't met. 8. **Honest accounting.** Cost/token metrics (incl. cache read/write) come exclusively from real API usage metadata, snapshotted per response, aggregated per conversation. 9. **Output truncation done right.** Middle-out head+tail truncation with the full output saved to disk and referenced, so nothing is irrecoverably lost. 10. **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 1. **Streaming is an afterthought.** `on_token` callbacks 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. 2. **The kernel is not small.** `Agent.step` and `LocalConversation` absorbed 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. 3. **Presentation welded into domain objects.** Every event/observation carries a Rich `visualize` property (emoji, color heuristics) — rendering policy trapped in the data layer. 4. **No terminal product.** The interactive surfaces are a web app and a debug visualizer; nothing here informs TUI excellence. 5. **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. 6. **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. 7. **Event-count context pressure.** Condensation triggers on number of events (`max_size=240`), not token budget; token pressure is handled reactively via exceptions. 8. **Default security analyzer is self-assessment.** The model rates the risk of its own actions; deterministic analysis exists but is opt-in. 9. **Background work via soft-timeout convention.** One terminal with `exit_code=-1`/`is_input` semantics the model must learn from prose descriptions, instead of an explicit process manager. 10. **Tree-of-events edge-case debt.** Legacy parent fallbacks, `ROOT_PARENT_ID`/`head_is_empty` sentinels, artifact-event skipping — powerful branching paid for with subtle invariants (#4057). ## WHAT KHAELOR SHOULD ADOPT 1. **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). 2. **View-as-projection + compaction-as-event**: a cached, incrementally-updated LLM view derived from events; `ContextCompacted` as 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. 3. **Stateless agent/kernel config + the resume contract** (tools add-only; model/config freely swappable). 4. **The str_replace editor playbook** for KHAELOR's `edit` tool: 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. 5. **Risk/policy separation grafted onto capabilities**: KHAELOR's deterministic capability evaluator (`file.write`, `process.execute`) as the analyzer; `allow/ask/deny` as 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. 6. **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. 7. **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). 8. **Middle-out truncation + full-output spill files** for tool observations; terminal observations annotated with cwd/exit-code metadata. 9. **Tool protocol shape**: typed Action/Observation schemas per tool, runtime executor excluded from serialization, behavior annotations (read-only/destructive), auto-derived names, `summary` argument on tool schemas for one-line action descriptions the TUI can display in collapsed tool rows. 10. **ripgrep-first search with explicit fallback**, bounded structured results. 11. **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 1. **The universal LLM adapter** (litellm, routers, fallback registries, dual API styles, non-native tool-calling mocks). KHAELOR is Anthropic-only behind one small `ModelClient`. 2. **Bolted-on streaming.** Invert it: KHAELOR's kernel emits `TextDelta`/`ToolInputDelta`/`ThinkingDelta` events natively; non-streaming is the degenerate case, never the default. 3. **The agent-server / Docker / remote-workspace stack** — FastAPI routers, WebSockets, PyInstaller specs, sandbox images. V1 is a local process; keep only a thin `Workspace` interface so remoting stays *possible*. 4. **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. 5. **`visualize` on domain objects.** Rendering lives in `src/tui/tool-view/`, keyed by event type — never on the event. 6. **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. 7. **The full event-*tree* in V1.** Keep `parent_id` in 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. 8. **Event-count-triggered compaction** — budget in tokens against the real context window. 9. **Soft-timeout terminal as the only background story** — KHAELOR's explicit `process` manager (start/list/read/write/stop) is the better design; keep the PTY session + metadata ideas, drop the `-1` exit-code convention as the primary mechanism. 10. **LLM self-assessed risk as the default gate** — deterministic capability rules first; self-assessment at most as a supplementary signal. 11. **Discriminated-union serialization frameworks** — use native TypeScript tagged unions with a `type` field and a schema-validated (e.g. zod) decode at the persistence boundary. --- *Author: Simon-Pierre Boucher · contact@spboucher.ai*