diff --git a/AGENTS.md b/AGENTS.md index 528f9c1e..f320a8e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,14 @@ Workflow: `.github/workflows/sync-agent-sdk-openapi.yml` - Use Mintlify components (``, ``, ``, etc.) where appropriate. - When linking internally, prefer **absolute** doc paths (e.g. `/overview/quickstart`). + +## AI-only invariants in SDK architecture docs + +- AI-only invariants live in sidecar files alongside the architecture pages: + - `sdk/arch/.ai-invariants.md` +- These files are excluded from the human docs and injected into `llms-full.txt` + by `scripts/generate-llms-files.py`. + ## LLM API Key Options The SDK documentation maintains three ways for users to obtain LLM access: diff --git a/llms-full.txt b/llms-full.txt index 10aea661..4c1c4a52 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4092,26 +4092,26 @@ Tools follow a **strict action-observation pattern**: flowchart TB LLM["LLM generates tool_call"] Convert["Convert to ActionEvent"] - + Decision{"Confirmation
mode?"} Defer["Store as pending"] - + Execute["Execute tool"] Success{"Success?"} - + Obs["ObservationEvent
with result"] Error["ObservationEvent
with error"] - + LLM --> Convert Convert --> Decision - + Decision -->|Yes| Defer Decision -->|No| Execute - + Execute --> Success Success -->|Yes| Obs Success -->|No| Error - + style Convert fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px style Execute fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px style Decision fill:#fff4df,stroke:#b7791f,stroke-width:2px @@ -4131,6 +4131,7 @@ Before execution, the security analyzer evaluates each action: - **Medium Risk:** Log warning, execute with monitoring - **High Risk:** Block execution, request user confirmation + ## Component Relationships ### How Agent Interacts @@ -4171,6 +4172,80 @@ flowchart LR - **[Skills](/sdk/arch/skill)** - Prompt engineering and skill patterns - **[LLM](/sdk/arch/llm)** - Language model abstraction +## Invariants (Normative) + +### AgentBase: Configuration is Stateless and Immutable + +Natural language invariant: + +- An `AgentBase` instance is a **pure configuration object**. It may cache materialized `ToolDefinition` instances internally, but it must remain valid to re-create those tools from its declarative spec. + +OCL-like: + +- `context AgentBase inv Frozen: self.model_config.frozen = true` + + +### Initialization: System Prompt Precedes Any User Message + +`Agent.init_state(state, on_event=...)` is responsible for creating the initial system prompt event. + +Natural language invariant: + +- A `ConversationState` must not contain a user `MessageEvent` before it contains a `SystemPromptEvent`. + +OCL-like (conceptual): + +- `context ConversationState inv SystemBeforeUser: self.events->select(e|e.oclIsKindOf(SystemPromptEvent))->size() >= 1 implies self.events->forAll(e| e.oclIsKindOf(MessageEvent) and e.source='user' implies e.index > systemPromptIndex )` + + +### Tool Materialization: Names Resolve to Registered ToolDefinitions + +An `Agent` is configured with a list of tool *specs* (`openhands.sdk.tool.spec.Tool`) that reference registered `ToolDefinition` factories. + +Natural language invariant: + +- `resolve_tool(Tool(name=X))` must succeed (tool name present in registry) for all tools the agent intends to use. +- Tool factories must return a **sequence** of `ToolDefinition` instances; tool sets (e.g., browser tool sets) are represented as multi-element sequences. + +### Multi-Tool Calls: Shared Thought Only on First ActionEvent + +When an LLM returns parallel tool calls, the SDK represents this as multiple `ActionEvent`s that share the same `llm_response_id`. + +Natural language invariant: + +- For a batch of `ActionEvent`s with the same `llm_response_id`, only the first action carries `thought` / `reasoning_content` / `thinking_blocks`; subsequent actions must have empty `thought`. + +OCL-like (as modeled in `event.base._combine_action_events`): + +- `context ActionEvent inv BatchedThoughtOnlyFirst: (self.llm_response_id = other.llm_response_id and self <> first) implies self.thought->isEmpty()` + + +### Confirmation Mode: Requires Both Analyzer and Policy + +`conversation.is_confirmation_mode_active` is true iff: + +- A `SecurityAnalyzer` is configured, and +- The confirmation policy is not `NeverConfirm`. + +OCL-like (conceptual): + +- `context BaseConversation inv ConfirmationModeIff: self.is_confirmation_mode_active = (self.state.security_analyzer <> null and not self.state.confirmation_policy.oclIsKindOf(NeverConfirm))` + + +**Execution Modes:** + +| Mode | Behavior | Use Case | +|------|----------|----------| +| **Direct** | Execute immediately | Development, trusted environments | +| **Confirmation** | Store as pending, wait for user approval | High-risk actions, production | + +**Security Integration:** + +Before execution, the security analyzer evaluates each action: +- **Low Risk:** Execute immediately +- **Medium Risk:** Log warning, execute with monitoring +- **High Risk:** Block execution, request user confirmation + ### Agent Server Package Source: https://docs.openhands.dev/sdk/arch/agent-server.md @@ -4707,7 +4782,11 @@ async def logging_middleware(request, call_next): ### Condenser Source: https://docs.openhands.dev/sdk/arch/condenser.md -The **Condenser** system manages conversation history compression to keep agent context within LLM token limits. It reduces long event histories into condensed summaries while preserving critical information for reasoning. For more details, read the [blog here](https://openhands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents). +The **Condenser** system manages conversation history compression to keep agent context within LLM token limits. It reduces long event histories into condensed summaries while preserving critical information for reasoning. + +For how condensation is represented in the event system (`Condensation`, `CondensationRequest`, and how they transform the LLM view), see **[Events Architecture](/sdk/arch/events)**. + +For more details, read the [blog here](https://openhands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents). **Source:** [`openhands-sdk/openhands/sdk/context/condenser/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/context/condenser) @@ -5293,6 +5372,7 @@ The conversation system provides pluggable services that operate independently o - Easy to add new services without changing core orchestration - Event stream acts as the integration point + ## Component Relationships ### How Conversation Interacts @@ -5329,6 +5409,47 @@ flowchart LR - **[Event System](/sdk/arch/events)** - Event types and flow - **[Conversation Usage Guide](/sdk/guides/convo-persistence)** - Practical examples +## Invariants (Normative) + +### Conversation Factory: Workspace Chooses Implementation + +Natural language invariant: + +- `Conversation(...)` is a factory that returns `LocalConversation` unless the provided `workspace` is a `RemoteWorkspace`. +- When `workspace` is remote, `persistence_dir` must be unset (`None`). + +OCL-like (conceptual): + +- `context Conversation::__new__ pre RemoteNoPersistence: workspace.oclIsKindOf(RemoteWorkspace) implies persistence_dir = null` + + +### ConversationState: Validated Snapshot + Event Log + +Natural language invariants: + +- `ConversationState` is the **only** component intended to hold mutable execution status (`IDLE`, `RUNNING`, `WAITING_FOR_CONFIRMATION`, etc.). +- `ConversationState` owns persistence (`FileStore`) and the event store; all other components treat persistence as an implementation detail. + +### Confirmation Mode Predicate + +The SDK exposes a single predicate for confirmation mode: + +- Confirmation mode is active iff `state.security_analyzer != None` **and** the confirmation policy is not `NeverConfirm`. + +### ask_agent() Must Be Stateless + +Natural language invariant (from the public contract): + +- `BaseConversation.ask_agent(question)` **must not** append events, mutate execution status, or persist anything. It is safe to call concurrently with `run()`. + +### Secrets Persistence Requires a Cipher + +Natural language invariant: + +- If `ConversationState` is persisted without a cipher, secret values are redacted and **cannot be recovered on restore**. + +(Implication: use `Cipher` when persistence is enabled and you expect to resume with secrets intact.) + ### Design Principles Source: https://docs.openhands.dev/sdk/arch/design.md @@ -5387,6 +5508,80 @@ Because agent logic was hard-coded into the core application, extending behavior Agents are defined as graphs of interchangeable components—tools, prompts, LLMs, and contexts—each described declaratively with strong typing. Developers can reconfigure capabilities (e.g., swap toolsets, override prompts, add delegation logic) without modifying core code, preserving stability while fostering rapid innovation. +--- + +## Design Invariants (Normative) + +This page describes the **architectural invariants** the SDK relies on. These are treated as *contracts* between components. + +Where appropriate, we express invariants in a lightweight OCL-like notation: + +- `context X inv Name: ` +- `pre:` / `post:` for pre/post-conditions + +If an invariant cannot be expressed precisely in OCL without significant auxiliary modeling, we state it in precise natural language. + +### Single Source of Truth for Runtime State + +The SDK is designed so that **all runtime state that affects agent execution is representable as an event log plus a small, validated state snapshot**. + +- **Configuration objects are immutable** (Pydantic `frozen=True` where applicable). +- **The only intentionally mutable entity is `ConversationState`**, which owns the event log, execution status, secrets registry, and persistence handles. + +OCL-like: + +- `context AgentBase inv StatelessConfiguration: self.model_config.frozen = true` +- `context Event inv Immutable: self.model_config.frozen = true` + + +Natural language invariant: + +- `ConversationState` is the single coordination point for execution. Other objects may maintain private runtime caches, but **must not** be required to restore or replay a conversation. + +### Workspace Boundary is the I/O Boundary + +All side effects against the environment (filesystem, processes, git operations) must occur **through a Workspace** (local or remote), which becomes the **I/O boundary**. + +- Tools may execute in different runtimes (local process vs inside agent-server), but *conceptually* they always operate against a workspace rooted at `workspace.working_dir`. + +OCL-like: + +- `context BaseWorkspace inv WorkingDirIsString: self.working_dir.oclIsTypeOf(String)` + + +### Event Log is the Execution Trace + +The event stream is the single authoritative trace of what the agent *saw* and *did*. + +Natural language invariant: + +- Any agent decision that should be reproducible on replay must be representable as an `LLMConvertibleEvent` (for LLM context) plus associated non-LLM events (e.g., state updates, errors). + +### Tool Calls are Explicit, Typed, and Linkable + +The SDK assumes an explicit `Action -> Observation` pairing. + +OCL-like (conceptual): + +- `context ActionEvent inv HasToolCallId: self.tool_call_id <> null` +- `context ObservationEvent inv RefersToAction: self.action_id <> null` + + +Natural language invariant: + +- Observations must be attributable to a specific action/tool call so that conversations can be audited, visualized, and resumed. + +### Remote vs Local is an Execution Detail + +The SDK makes *deployment mode* (local vs remote) a **runtime selection behind a common interface**, not two separate programming models. + +- `Conversation(...)` returns either `LocalConversation` or `RemoteConversation` based on the provided workspace. +- User-facing code typically should not need to change when switching workspaces; you mostly swap configuration. + + +This does **not** mean every optional method behaves identically across workspace types (e.g., `pause()` / `resume()` may be a no-op locally and meaningful remotely). The core conversation API (`send_message`, `run`, events) stays consistent. + + ### Events Source: https://docs.openhands.dev/sdk/arch/events.md @@ -5536,6 +5731,8 @@ Events for metadata, control flow, and user actions (not sent to LLM): - **agent**: Event generated by agent logic - **environment**: Event from system/framework/tools + + ## Component Relationships ### How Events Integrate @@ -5610,6 +5807,57 @@ Two distinct error events exist in the SDK, with different purpose and visibilit - **[Tool System](/sdk/arch/tool-system)** - ActionEvent and ObservationEvent generation - **[Condenser](/sdk/arch/condenser)** - Event history compression +## Invariants (Normative) + +### Event Immutability + +All events inherit from `Event` / `LLMConvertibleEvent` with Pydantic config `frozen=True` and `extra="forbid"`. + +Natural language invariant: + +- Once appended to the event log, an event must be treated as immutable. Mutations are represented as *new events*, not edits. + +OCL-like: + +- `context Event inv Frozen: self.model_config.frozen = true` + + +### LLM-Convertible Stream Can Be Reconstructed Deterministically + +Natural language invariant: + +- `LLMConvertibleEvent.events_to_messages(events)` must produce the exact LLM message stream used for decision making, including batching of parallel tool calls. + +### Parallel Tool Calls are Batched by llm_response_id + +When multiple `ActionEvent`s share the same `llm_response_id`, they represent a single assistant turn with multiple tool calls. + +Natural language invariant: + +- In a batch, only the first `ActionEvent` may contain `thought`/reasoning; subsequent actions must have empty `thought`. This is asserted when combining events. + +### Condensation is a Pure View Transformation + +`Condensation.apply(events)` removes forgotten events and optionally inserts a synthetic `CondensationSummaryEvent` at `summary_offset`. + +Natural language invariants: + +- Condensation never mutates existing events; it returns a new list. +- `forgotten_event_ids` must refer to events that exist in the input list (otherwise the operation is a no-op for those IDs). +- If `summary` is present, `summary_offset` must also be present to insert the summary into the view; otherwise the summary is metadata only. + +OCL-like (conceptual): + +- `context Condensation inv SummaryOffsetPair: (self.summary <> null) implies (self.summary_offset <> null) or true -- insertion requires both; metadata-only summary allowed` + + +For the condenser algorithms, thresholds, and configuration, see **[Condenser Architecture](/sdk/arch/condenser)**. + +**Source Types:** +- **user**: Event originated from user input +- **agent**: Event generated by agent logic +- **environment**: Event from system/framework/tools + ### LLM Source: https://docs.openhands.dev/sdk/arch/llm.md @@ -8402,6 +8650,90 @@ flowchart TB - **[Custom Tools Guide](/sdk/guides/custom-tools)** - Building your own tools - **[FastMCP Documentation](https://gofastmcp.com/)** - Underlying MCP client library +## Invariants (Normative) + +### ToolDefinition Naming + +By default, tool names are derived from the class name: + +- `TerminalTool` → `terminal` +- `FileEditorTool` → `file_editor` + +Natural language invariant: + +- Unless explicitly overridden, `ToolDefinition.name` is deterministic and stable across runs. + +### Tool Registry + +`register_tool(name, factory)` maintains a global name→resolver mapping. + +Invariants: + +- Tool names must be non-empty strings. +- A `ToolDefinition` instance can only be registered if it has a non-None `executor`. +- A `ToolDefinition` subclass can only be registered if it implements a concrete `create(...)` classmethod that returns `Sequence[ToolDefinition]`. +- Resolving an unregistered tool name must raise `KeyError`. + +OCL-like (conceptual): + +- `context ToolRegistry inv NonEmptyNames: name.trim().size() > 0` + + +### Executor Presence and Call Semantics + +Natural language invariant: + +- A `ToolDefinition` without an `executor` is not executable; attempts to call it must fail fast. +- All tool execution is performed in a `LocalConversation` context (even when invoked remotely) because the agent-server hosts the actual conversation that runs tools. + +### Action/Observation Schemas are Validated + +Natural language invariant: + +- `Action` and `Observation` are Pydantic models; tool inputs are validated before execution, and tool results are **parsed/validated** into the declared observation model (if present). If the executor already returns the correct observation type, this is a no-op. + + +1. **[Tool (Spec)](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/spec.py)** - Configuration object with `name` (e.g., "BashTool") and `params` (e.g., `{"working_dir": "/workspace"}`) +2. **Resolver Lookup** - Registry finds the registered resolver for the tool name +3. **Factory Invocation** - Resolver calls the tool's `.create()` method with params and conversation state +4. **Instance Creation** - Tool instance(s) are created with configured executors +5. **Agent Usage** - Instances are added to the agent's tools_map for execution + +**Registration Types:** + +| Type | Registration | Resolver Behavior | +|------|-------------|-------------------| +| **Tool Instance** | `register_tool(name, instance)` | Returns the fixed instance (params not allowed) | +| **Tool Subclass** | `register_tool(name, ToolClass)` | Calls `ToolClass.create(**params, conv_state=state)` | +| **Factory Function** | `register_tool(name, factory)` | Calls `factory(**params, conv_state=state)` | + +### File Organization + +Tools follow a consistent file structure for maintainability: + +``` +openhands-tools/openhands/tools/my_tool/ +├── __init__.py # Export MyTool +├── definition.py # Action, Observation, MyTool(ToolDefinition) +├── impl.py # MyExecutor(ToolExecutor) +└── [other modules] # Tool-specific utilities +``` + +**File Responsibilities:** + +| File | Contains | Purpose | +|------|----------|---------| +| `definition.py` | Action, Observation, ToolDefinition subclass | Public API, schema definitions, factory method | +| `impl.py` | ToolExecutor implementation | Business logic, state management, execution | +| `__init__.py` | Tool exports | Package interface | + +**Benefits:** +- **Separation of Concerns** - Public API separate from implementation +- **Avoid Circular Imports** - Import `impl` only inside `create()` method +- **Consistency** - All tools follow same structure for discoverability + +**Example Reference:** See [`terminal/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools/terminal) for complete implementation + ### Workspace Source: https://docs.openhands.dev/sdk/arch/workspace.md @@ -8532,6 +8864,7 @@ flowchart LR | **Download** | `shutil.copy()` | `GET /file/download` stream | | **Result** | `FileOperationResult` | `FileOperationResult` | + ## Resource Management Workspaces use **context manager** for safe resource handling: @@ -8608,6 +8941,73 @@ flowchart LR - **[Agent Server](/sdk/arch/agent-server)** - Remote execution API - **[Tool System](/sdk/arch/tool-system)** - Tools that use workspace for execution +## Invariants (Normative) + +### Workspace Factory: Host Chooses Remote + +The `Workspace(...)` constructor is a factory: + +- If `host` is provided, it returns a `RemoteWorkspace`. +- Otherwise it returns a `LocalWorkspace`. + +OCL-like (conceptual): + +- `context Workspace::__new__ post RemoteIffHost: (host <> null) implies result.oclIsKindOf(RemoteWorkspace)` + + +### BaseWorkspace Contract + +All workspace implementations must satisfy: + +- `execute_command(command, cwd, timeout)` returns a `CommandResult` where `exit_code=-1` indicates timeout. +- `file_upload` / `file_download` return a `FileOperationResult` with `success=false` and a populated `error` field on failure. +- Git helpers (`git_changes`, `git_diff`) must raise if the path is not a git repository. + +### working_dir Normalization + +Natural language invariant: + +- `working_dir` is normalized to a `str` even if passed as a `Path`. + +### Pause/Resume Semantics (Optional Capability) + +`pause()` / `resume()` are intentionally **optional capabilities**: + +- `LocalWorkspace.pause()` / `.resume()` are no-ops. +- Remote/container workspaces may implement pause/resume to conserve resources. +- If a workspace type does not support pausing, it must raise `NotImplementedError`. + +#### Discussion: `pause()` / `resume()` semantics (design tradeoff) + +There is an argument that this is compatible with the “swap workspaces without rewriting code” principle, because most client code should only rely on the *core* workspace and conversation operations, while optional capabilities are feature-detected or used conditionally. + +There is a mild design smell here: the method names `pause()` / `resume()` suggest a strong guarantee (that work is actually suspended), but the SDK currently treats them as a **best-effort resource management hook**. + +- Locally, there is often nothing meaningful the workspace can suspend at the boundary (it is operating on the host OS), so `LocalWorkspace.pause()` is a no-op. +- Some remote/container workspaces may be able to pause a container or VM, but others may not. + +This tension matters because it creates two different reasonable expectations: + +1. *Ergonomic expectation*: orchestration code can call `pause()` unconditionally and it will be safe. +2. *Guarantee expectation*: calling `pause()` actually pauses resource usage. + +**Maybe it would make sense to** model this explicitly as an optional capability: + +- Add `supports_pause` (or a richer `pause_capability`) to `BaseWorkspace`, and +- Make `pause()` / `resume()` no-ops everywhere by default (including remote) while letting pausable implementations override, +- Keep a strict helper (e.g., `pause_or_raise()`) for callers who require a guarantee. + +This would make the default behavior unsurprising (safe to call), while still letting clients opt into fail-fast behavior when pausing is required. + + +### File Operations + +| Operation | Local Implementation | Remote Implementation | +|-----------|---------------------|----------------------| +| **Upload** | `shutil.copy()` | `POST /file/upload` with multipart | +| **Download** | `shutil.copy()` | `GET /file/download` stream | +| **Result** | `FileOperationResult` | `FileOperationResult` | + ### FAQ Source: https://docs.openhands.dev/sdk/faq.md diff --git a/scripts/generate-llms-files.py b/scripts/generate-llms-files.py index 543456af..4a02797f 100755 --- a/scripts/generate-llms-files.py +++ b/scripts/generate-llms-files.py @@ -51,6 +51,7 @@ BASE_URL = "https://docs.openhands.dev" EXCLUDED_DIRS = {".git", ".github", ".agents", "tests", "openapi", "logo"} +AI_INVARIANTS_SUFFIX = ".ai-invariants.md" @dataclass(frozen=True) @@ -60,6 +61,7 @@ class DocPage: title: str description: str | None body: str + ai_invariants: str | None _FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL) @@ -99,6 +101,15 @@ def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: return fm, body +def load_ai_invariants(rel_path: Path) -> str | None: + sidecar_path = rel_path.with_suffix(AI_INVARIANTS_SUFFIX) + full_path = ROOT / sidecar_path + if not full_path.exists(): + return None + content = full_path.read_text(encoding="utf-8").strip() + return content or None + + def rel_to_route(rel_path: Path) -> str: p = rel_path.as_posix() if p.endswith(".mdx"): @@ -132,6 +143,7 @@ def iter_doc_pages() -> list[DocPage]: raw = mdx_path.read_text(encoding="utf-8") fm, body = parse_frontmatter(raw) + ai_invariants = load_ai_invariants(rel_path) title = fm.get("title") if not title: @@ -147,6 +159,7 @@ def iter_doc_pages() -> list[DocPage]: title=title, description=description, body=body.strip(), + ai_invariants=ai_invariants, ) ) @@ -228,7 +241,7 @@ def build_llms_txt(pages: list[DocPage]) -> str: "", "> LLM-friendly index of OpenHands documentation (V1). Legacy V0 docs pages are intentionally excluded.", "", - "The sections below intentionally separate OpenHands product documentation (Web App Server / Cloud / CLI)", + "The sections below intentionally separate OpenHands applications documentation (Web App Server / Cloud / CLI)", "from the OpenHands Software Agent SDK.", "", ] @@ -285,6 +298,10 @@ def build_llms_full_txt(pages: list[DocPage]) -> str: lines.append(page.body) lines.append("") + if page.ai_invariants: + lines.append(page.ai_invariants) + lines.append("") + return "\n".join(lines).rstrip() + "\n" diff --git a/sdk/arch/agent.ai-invariants.md b/sdk/arch/agent.ai-invariants.md new file mode 100644 index 00000000..12645b51 --- /dev/null +++ b/sdk/arch/agent.ai-invariants.md @@ -0,0 +1,73 @@ +## Invariants (Normative) + +### AgentBase: Configuration is Stateless and Immutable + +Natural language invariant: + +- An `AgentBase` instance is a **pure configuration object**. It may cache materialized `ToolDefinition` instances internally, but it must remain valid to re-create those tools from its declarative spec. + +OCL-like: + +- `context AgentBase inv Frozen: self.model_config.frozen = true` + + +### Initialization: System Prompt Precedes Any User Message + +`Agent.init_state(state, on_event=...)` is responsible for creating the initial system prompt event. + +Natural language invariant: + +- A `ConversationState` must not contain a user `MessageEvent` before it contains a `SystemPromptEvent`. + +OCL-like (conceptual): + +- `context ConversationState inv SystemBeforeUser: self.events->select(e|e.oclIsKindOf(SystemPromptEvent))->size() >= 1 implies self.events->forAll(e| e.oclIsKindOf(MessageEvent) and e.source='user' implies e.index > systemPromptIndex )` + + +### Tool Materialization: Names Resolve to Registered ToolDefinitions + +An `Agent` is configured with a list of tool *specs* (`openhands.sdk.tool.spec.Tool`) that reference registered `ToolDefinition` factories. + +Natural language invariant: + +- `resolve_tool(Tool(name=X))` must succeed (tool name present in registry) for all tools the agent intends to use. +- Tool factories must return a **sequence** of `ToolDefinition` instances; tool sets (e.g., browser tool sets) are represented as multi-element sequences. + +### Multi-Tool Calls: Shared Thought Only on First ActionEvent + +When an LLM returns parallel tool calls, the SDK represents this as multiple `ActionEvent`s that share the same `llm_response_id`. + +Natural language invariant: + +- For a batch of `ActionEvent`s with the same `llm_response_id`, only the first action carries `thought` / `reasoning_content` / `thinking_blocks`; subsequent actions must have empty `thought`. + +OCL-like (as modeled in `event.base._combine_action_events`): + +- `context ActionEvent inv BatchedThoughtOnlyFirst: (self.llm_response_id = other.llm_response_id and self <> first) implies self.thought->isEmpty()` + + +### Confirmation Mode: Requires Both Analyzer and Policy + +`conversation.is_confirmation_mode_active` is true iff: + +- A `SecurityAnalyzer` is configured, and +- The confirmation policy is not `NeverConfirm`. + +OCL-like (conceptual): + +- `context BaseConversation inv ConfirmationModeIff: self.is_confirmation_mode_active = (self.state.security_analyzer <> null and not self.state.confirmation_policy.oclIsKindOf(NeverConfirm))` + + +**Execution Modes:** + +| Mode | Behavior | Use Case | +|------|----------|----------| +| **Direct** | Execute immediately | Development, trusted environments | +| **Confirmation** | Store as pending, wait for user approval | High-risk actions, production | + +**Security Integration:** + +Before execution, the security analyzer evaluates each action: +- **Low Risk:** Execute immediately +- **Medium Risk:** Log warning, execute with monitoring +- **High Risk:** Block execution, request user confirmation diff --git a/sdk/arch/agent.mdx b/sdk/arch/agent.mdx index 22b0e134..94ce23a9 100644 --- a/sdk/arch/agent.mdx +++ b/sdk/arch/agent.mdx @@ -199,26 +199,26 @@ Tools follow a **strict action-observation pattern**: flowchart TB LLM["LLM generates tool_call"] Convert["Convert to ActionEvent"] - + Decision{"Confirmation
mode?"} Defer["Store as pending"] - + Execute["Execute tool"] Success{"Success?"} - + Obs["ObservationEvent
with result"] Error["ObservationEvent
with error"] - + LLM --> Convert Convert --> Decision - + Decision -->|Yes| Defer Decision -->|No| Execute - + Execute --> Success Success -->|Yes| Obs Success -->|No| Error - + style Convert fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px style Execute fill:#e8f3ff,stroke:#2b6cb0,stroke-width:2px style Decision fill:#fff4df,stroke:#b7791f,stroke-width:2px @@ -238,6 +238,7 @@ Before execution, the security analyzer evaluates each action: - **Medium Risk:** Log warning, execute with monitoring - **High Risk:** Block execution, request user confirmation + ## Component Relationships ### How Agent Interacts diff --git a/sdk/arch/condenser.mdx b/sdk/arch/condenser.mdx index f5702ce0..310c3133 100644 --- a/sdk/arch/condenser.mdx +++ b/sdk/arch/condenser.mdx @@ -3,7 +3,11 @@ title: Condenser description: High-level architecture of the conversation history compression system --- -The **Condenser** system manages conversation history compression to keep agent context within LLM token limits. It reduces long event histories into condensed summaries while preserving critical information for reasoning. For more details, read the [blog here](https://openhands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents). +The **Condenser** system manages conversation history compression to keep agent context within LLM token limits. It reduces long event histories into condensed summaries while preserving critical information for reasoning. + +For how condensation is represented in the event system (`Condensation`, `CondensationRequest`, and how they transform the LLM view), see **[Events Architecture](/sdk/arch/events)**. + +For more details, read the [blog here](https://openhands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents). **Source:** [`openhands-sdk/openhands/sdk/context/condenser/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-sdk/openhands/sdk/context/condenser) diff --git a/sdk/arch/conversation.ai-invariants.md b/sdk/arch/conversation.ai-invariants.md new file mode 100644 index 00000000..9e5139e5 --- /dev/null +++ b/sdk/arch/conversation.ai-invariants.md @@ -0,0 +1,40 @@ +## Invariants (Normative) + +### Conversation Factory: Workspace Chooses Implementation + +Natural language invariant: + +- `Conversation(...)` is a factory that returns `LocalConversation` unless the provided `workspace` is a `RemoteWorkspace`. +- When `workspace` is remote, `persistence_dir` must be unset (`None`). + +OCL-like (conceptual): + +- `context Conversation::__new__ pre RemoteNoPersistence: workspace.oclIsKindOf(RemoteWorkspace) implies persistence_dir = null` + + +### ConversationState: Validated Snapshot + Event Log + +Natural language invariants: + +- `ConversationState` is the **only** component intended to hold mutable execution status (`IDLE`, `RUNNING`, `WAITING_FOR_CONFIRMATION`, etc.). +- `ConversationState` owns persistence (`FileStore`) and the event store; all other components treat persistence as an implementation detail. + +### Confirmation Mode Predicate + +The SDK exposes a single predicate for confirmation mode: + +- Confirmation mode is active iff `state.security_analyzer != None` **and** the confirmation policy is not `NeverConfirm`. + +### ask_agent() Must Be Stateless + +Natural language invariant (from the public contract): + +- `BaseConversation.ask_agent(question)` **must not** append events, mutate execution status, or persist anything. It is safe to call concurrently with `run()`. + +### Secrets Persistence Requires a Cipher + +Natural language invariant: + +- If `ConversationState` is persisted without a cipher, secret values are redacted and **cannot be recovered on restore**. + +(Implication: use `Cipher` when persistence is enabled and you expect to resume with secrets intact.) diff --git a/sdk/arch/conversation.mdx b/sdk/arch/conversation.mdx index 99cc00c7..77bbfbe3 100644 --- a/sdk/arch/conversation.mdx +++ b/sdk/arch/conversation.mdx @@ -193,6 +193,7 @@ The conversation system provides pluggable services that operate independently o - Easy to add new services without changing core orchestration - Event stream acts as the integration point + ## Component Relationships ### How Conversation Interacts diff --git a/sdk/arch/design.ai-invariants.md b/sdk/arch/design.ai-invariants.md new file mode 100644 index 00000000..164a75d0 --- /dev/null +++ b/sdk/arch/design.ai-invariants.md @@ -0,0 +1,71 @@ +## Design Invariants (Normative) + +This page describes the **architectural invariants** the SDK relies on. These are treated as *contracts* between components. + +Where appropriate, we express invariants in a lightweight OCL-like notation: + +- `context X inv Name: ` +- `pre:` / `post:` for pre/post-conditions + +If an invariant cannot be expressed precisely in OCL without significant auxiliary modeling, we state it in precise natural language. + +### Single Source of Truth for Runtime State + +The SDK is designed so that **all runtime state that affects agent execution is representable as an event log plus a small, validated state snapshot**. + +- **Configuration objects are immutable** (Pydantic `frozen=True` where applicable). +- **The only intentionally mutable entity is `ConversationState`**, which owns the event log, execution status, secrets registry, and persistence handles. + +OCL-like: + +- `context AgentBase inv StatelessConfiguration: self.model_config.frozen = true` +- `context Event inv Immutable: self.model_config.frozen = true` + + +Natural language invariant: + +- `ConversationState` is the single coordination point for execution. Other objects may maintain private runtime caches, but **must not** be required to restore or replay a conversation. + +### Workspace Boundary is the I/O Boundary + +All side effects against the environment (filesystem, processes, git operations) must occur **through a Workspace** (local or remote), which becomes the **I/O boundary**. + +- Tools may execute in different runtimes (local process vs inside agent-server), but *conceptually* they always operate against a workspace rooted at `workspace.working_dir`. + +OCL-like: + +- `context BaseWorkspace inv WorkingDirIsString: self.working_dir.oclIsTypeOf(String)` + + +### Event Log is the Execution Trace + +The event stream is the single authoritative trace of what the agent *saw* and *did*. + +Natural language invariant: + +- Any agent decision that should be reproducible on replay must be representable as an `LLMConvertibleEvent` (for LLM context) plus associated non-LLM events (e.g., state updates, errors). + +### Tool Calls are Explicit, Typed, and Linkable + +The SDK assumes an explicit `Action -> Observation` pairing. + +OCL-like (conceptual): + +- `context ActionEvent inv HasToolCallId: self.tool_call_id <> null` +- `context ObservationEvent inv RefersToAction: self.action_id <> null` + + +Natural language invariant: + +- Observations must be attributable to a specific action/tool call so that conversations can be audited, visualized, and resumed. + +### Remote vs Local is an Execution Detail + +The SDK makes *deployment mode* (local vs remote) a **runtime selection behind a common interface**, not two separate programming models. + +- `Conversation(...)` returns either `LocalConversation` or `RemoteConversation` based on the provided workspace. +- User-facing code typically should not need to change when switching workspaces; you mostly swap configuration. + + +This does **not** mean every optional method behaves identically across workspace types (e.g., `pause()` / `resume()` may be a no-op locally and meaningful remotely). The core conversation API (`send_message`, `run`, events) stays consistent. + diff --git a/sdk/arch/design.mdx b/sdk/arch/design.mdx index e946d9f9..3fb3df93 100644 --- a/sdk/arch/design.mdx +++ b/sdk/arch/design.mdx @@ -58,3 +58,5 @@ Because agent logic was hard-coded into the core application, extending behavior **Everything should be composable and safe to extend.** Agents are defined as graphs of interchangeable components—tools, prompts, LLMs, and contexts—each described declaratively with strong typing. Developers can reconfigure capabilities (e.g., swap toolsets, override prompts, add delegation logic) without modifying core code, preserving stability while fostering rapid innovation. + +--- diff --git a/sdk/arch/events.ai-invariants.md b/sdk/arch/events.ai-invariants.md new file mode 100644 index 00000000..0b18acbd --- /dev/null +++ b/sdk/arch/events.ai-invariants.md @@ -0,0 +1,50 @@ +## Invariants (Normative) + +### Event Immutability + +All events inherit from `Event` / `LLMConvertibleEvent` with Pydantic config `frozen=True` and `extra="forbid"`. + +Natural language invariant: + +- Once appended to the event log, an event must be treated as immutable. Mutations are represented as *new events*, not edits. + +OCL-like: + +- `context Event inv Frozen: self.model_config.frozen = true` + + +### LLM-Convertible Stream Can Be Reconstructed Deterministically + +Natural language invariant: + +- `LLMConvertibleEvent.events_to_messages(events)` must produce the exact LLM message stream used for decision making, including batching of parallel tool calls. + +### Parallel Tool Calls are Batched by llm_response_id + +When multiple `ActionEvent`s share the same `llm_response_id`, they represent a single assistant turn with multiple tool calls. + +Natural language invariant: + +- In a batch, only the first `ActionEvent` may contain `thought`/reasoning; subsequent actions must have empty `thought`. This is asserted when combining events. + +### Condensation is a Pure View Transformation + +`Condensation.apply(events)` removes forgotten events and optionally inserts a synthetic `CondensationSummaryEvent` at `summary_offset`. + +Natural language invariants: + +- Condensation never mutates existing events; it returns a new list. +- `forgotten_event_ids` must refer to events that exist in the input list (otherwise the operation is a no-op for those IDs). +- If `summary` is present, `summary_offset` must also be present to insert the summary into the view; otherwise the summary is metadata only. + +OCL-like (conceptual): + +- `context Condensation inv SummaryOffsetPair: (self.summary <> null) implies (self.summary_offset <> null) or true -- insertion requires both; metadata-only summary allowed` + + +For the condenser algorithms, thresholds, and configuration, see **[Condenser Architecture](/sdk/arch/condenser)**. + +**Source Types:** +- **user**: Event originated from user input +- **agent**: Event generated by agent logic +- **environment**: Event from system/framework/tools diff --git a/sdk/arch/tool-system.ai-invariants.md b/sdk/arch/tool-system.ai-invariants.md new file mode 100644 index 00000000..141a0cf7 --- /dev/null +++ b/sdk/arch/tool-system.ai-invariants.md @@ -0,0 +1,83 @@ +## Invariants (Normative) + +### ToolDefinition Naming + +By default, tool names are derived from the class name: + +- `TerminalTool` → `terminal` +- `FileEditorTool` → `file_editor` + +Natural language invariant: + +- Unless explicitly overridden, `ToolDefinition.name` is deterministic and stable across runs. + +### Tool Registry + +`register_tool(name, factory)` maintains a global name→resolver mapping. + +Invariants: + +- Tool names must be non-empty strings. +- A `ToolDefinition` instance can only be registered if it has a non-None `executor`. +- A `ToolDefinition` subclass can only be registered if it implements a concrete `create(...)` classmethod that returns `Sequence[ToolDefinition]`. +- Resolving an unregistered tool name must raise `KeyError`. + +OCL-like (conceptual): + +- `context ToolRegistry inv NonEmptyNames: name.trim().size() > 0` + + +### Executor Presence and Call Semantics + +Natural language invariant: + +- A `ToolDefinition` without an `executor` is not executable; attempts to call it must fail fast. +- All tool execution is performed in a `LocalConversation` context (even when invoked remotely) because the agent-server hosts the actual conversation that runs tools. + +### Action/Observation Schemas are Validated + +Natural language invariant: + +- `Action` and `Observation` are Pydantic models; tool inputs are validated before execution, and tool results are **parsed/validated** into the declared observation model (if present). If the executor already returns the correct observation type, this is a no-op. + + +1. **[Tool (Spec)](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-sdk/openhands/sdk/tool/spec.py)** - Configuration object with `name` (e.g., "BashTool") and `params` (e.g., `{"working_dir": "/workspace"}`) +2. **Resolver Lookup** - Registry finds the registered resolver for the tool name +3. **Factory Invocation** - Resolver calls the tool's `.create()` method with params and conversation state +4. **Instance Creation** - Tool instance(s) are created with configured executors +5. **Agent Usage** - Instances are added to the agent's tools_map for execution + +**Registration Types:** + +| Type | Registration | Resolver Behavior | +|------|-------------|-------------------| +| **Tool Instance** | `register_tool(name, instance)` | Returns the fixed instance (params not allowed) | +| **Tool Subclass** | `register_tool(name, ToolClass)` | Calls `ToolClass.create(**params, conv_state=state)` | +| **Factory Function** | `register_tool(name, factory)` | Calls `factory(**params, conv_state=state)` | + +### File Organization + +Tools follow a consistent file structure for maintainability: + +``` +openhands-tools/openhands/tools/my_tool/ +├── __init__.py # Export MyTool +├── definition.py # Action, Observation, MyTool(ToolDefinition) +├── impl.py # MyExecutor(ToolExecutor) +└── [other modules] # Tool-specific utilities +``` + +**File Responsibilities:** + +| File | Contains | Purpose | +|------|----------|---------| +| `definition.py` | Action, Observation, ToolDefinition subclass | Public API, schema definitions, factory method | +| `impl.py` | ToolExecutor implementation | Business logic, state management, execution | +| `__init__.py` | Tool exports | Package interface | + +**Benefits:** +- **Separation of Concerns** - Public API separate from implementation +- **Avoid Circular Imports** - Import `impl` only inside `create()` method +- **Consistency** - All tools follow same structure for discoverability + +**Example Reference:** See [`terminal/`](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-tools/openhands/tools/terminal) for complete implementation diff --git a/sdk/arch/workspace.ai-invariants.md b/sdk/arch/workspace.ai-invariants.md new file mode 100644 index 00000000..385e345a --- /dev/null +++ b/sdk/arch/workspace.ai-invariants.md @@ -0,0 +1,66 @@ +## Invariants (Normative) + +### Workspace Factory: Host Chooses Remote + +The `Workspace(...)` constructor is a factory: + +- If `host` is provided, it returns a `RemoteWorkspace`. +- Otherwise it returns a `LocalWorkspace`. + +OCL-like (conceptual): + +- `context Workspace::__new__ post RemoteIffHost: (host <> null) implies result.oclIsKindOf(RemoteWorkspace)` + + +### BaseWorkspace Contract + +All workspace implementations must satisfy: + +- `execute_command(command, cwd, timeout)` returns a `CommandResult` where `exit_code=-1` indicates timeout. +- `file_upload` / `file_download` return a `FileOperationResult` with `success=false` and a populated `error` field on failure. +- Git helpers (`git_changes`, `git_diff`) must raise if the path is not a git repository. + +### working_dir Normalization + +Natural language invariant: + +- `working_dir` is normalized to a `str` even if passed as a `Path`. + +### Pause/Resume Semantics (Optional Capability) + +`pause()` / `resume()` are intentionally **optional capabilities**: + +- `LocalWorkspace.pause()` / `.resume()` are no-ops. +- Remote/container workspaces may implement pause/resume to conserve resources. +- If a workspace type does not support pausing, it must raise `NotImplementedError`. + +#### Discussion: `pause()` / `resume()` semantics (design tradeoff) + +There is an argument that this is compatible with the “swap workspaces without rewriting code” principle, because most client code should only rely on the *core* workspace and conversation operations, while optional capabilities are feature-detected or used conditionally. + +There is a mild design smell here: the method names `pause()` / `resume()` suggest a strong guarantee (that work is actually suspended), but the SDK currently treats them as a **best-effort resource management hook**. + +- Locally, there is often nothing meaningful the workspace can suspend at the boundary (it is operating on the host OS), so `LocalWorkspace.pause()` is a no-op. +- Some remote/container workspaces may be able to pause a container or VM, but others may not. + +This tension matters because it creates two different reasonable expectations: + +1. *Ergonomic expectation*: orchestration code can call `pause()` unconditionally and it will be safe. +2. *Guarantee expectation*: calling `pause()` actually pauses resource usage. + +**Maybe it would make sense to** model this explicitly as an optional capability: + +- Add `supports_pause` (or a richer `pause_capability`) to `BaseWorkspace`, and +- Make `pause()` / `resume()` no-ops everywhere by default (including remote) while letting pausable implementations override, +- Keep a strict helper (e.g., `pause_or_raise()`) for callers who require a guarantee. + +This would make the default behavior unsurprising (safe to call), while still letting clients opt into fail-fast behavior when pausing is required. + + +### File Operations + +| Operation | Local Implementation | Remote Implementation | +|-----------|---------------------|----------------------| +| **Upload** | `shutil.copy()` | `POST /file/upload` with multipart | +| **Download** | `shutil.copy()` | `GET /file/download` stream | +| **Result** | `FileOperationResult` | `FileOperationResult` | diff --git a/sdk/arch/workspace.mdx b/sdk/arch/workspace.mdx index dbff538b..74c93fd9 100644 --- a/sdk/arch/workspace.mdx +++ b/sdk/arch/workspace.mdx @@ -130,6 +130,7 @@ flowchart LR | **Download** | `shutil.copy()` | `GET /file/download` stream | | **Result** | `FileOperationResult` | `FileOperationResult` | + ## Resource Management Workspaces use **context manager** for safe resource handling: