From f710af3ab08966b9793812d81a44d028357c5fec Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Fri, 31 Jul 2026 17:03:06 +0200 Subject: [PATCH 1/7] Research: provider-native tool calling APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete research bead openhands-agent-tools-research. Created comprehensive research document at docs/NATIVE_TOOLS_RESEARCH.md covering: - Anthropic Messages API: tools array with name/description/input_schema, tool_use content blocks, tool_result continuation in user messages - Gemini Interactions API: type:function tools, function_call steps, function_result inputs, previous_interaction_id for stateful mode (NOTE: use Interactions API, not legacy generateContent) - OpenAI-compatible: reuse Chat Completions shape, provider gating strategy Captured proven data shapes from oh-tab reference implementation and official docs. Documented 4-phase implementation plan: Anthropic → Gemini Interactions migration + tools → OpenAI-compatible gating → cross-provider validation. All official docs referenced with exact request/response formats, tool-call IDs, continuation shapes, and edge cases noted. Co-authored-by: smolpaws --- .beads/issues.jsonl | 2 +- docs/NATIVE_TOOLS_RESEARCH.md | 525 ++++++++++++++++++++++++++++++++++ 2 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 docs/NATIVE_TOOLS_RESEARCH.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0a9e91b..4113fcd 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -31,7 +31,7 @@ {"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Acceptance: unit tests cover Interactions request tool declarations, no-tools omission, function-call parsing, function-response/tool-result continuation, coexistence with thought signatures, and no regression in existing Gemini tests. Use current official Google Interactions API docs as the authority.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} {"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"OpenAI native tool passing is already done by PR #7; this epic tracks the remaining provider clients only.","status":"open","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","tools","transpile"]} {"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Acceptance: tests cover OpenAI-compatible chat payload tools, no-tools omission, custom baseUrl/proxy behavior, OpenRouter behavior if supported, and clear docs for any disabled or unverified provider dialect.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} -{"id":"openhands-agent-tools-research","title":"Research provider-native tool APIs and bounded oh-tab reference","description":"Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.","notes":"Acceptance: notes identify exact provider request/response shapes, stop/finish semantics, tool-call ids, tool-result message/input item shapes, and unsupported/provider-gated cases. Include links to current official Anthropic and Google Interactions API docs. Official docs to use: Anthropic Tool Use https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview; Gemini Interactions overview https://ai.google.dev/gemini-api/docs/interactions-overview; Gemini Interactions API reference https://ai.google.dev/api/interactions-api; Gemini migration notes https://ai.google.dev/gemini-api/docs/migrate-to-interactions.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["anthropic","gemini","llm","openai-compatible","research","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-research","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id": "openhands-agent-tools-research", "title": "Research provider-native tool APIs and bounded oh-tab reference", "description": "Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.", "notes": "Completed 2026-07-30: Research document at docs/NATIVE_TOOLS_RESEARCH.md covers Anthropic (tools array, tool_use blocks, tool_result), Gemini Interactions (type:function, function_call steps, function_result, stateful mode), OpenAI-compatible (reuse Chat shape, provider gating). Captured oh-tab patterns. 4-phase plan: Anthropic \u2192 Gemini + migration \u2192 OpenAI-compatible \u2192 validation.", "status": "closed", "priority": 1, "issue_type": "task", "created_at": "2026-07-15T01:12:47.261769+02:00", "updated_at": "2026-07-31T17:01:34.378353+02:00", "labels": ["anthropic", "gemini", "llm", "openai-compatible", "research", "tools"], "dependencies": [{"issue_id": "openhands-agent-tools-research", "depends_on_id": "openhands-agent-tools-native", "type": "parent-child", "created_at": "2026-07-15T01:12:47.261769+02:00", "created_by": "openhands"}], "closed_at": "2026-07-31T17:01:34.378353+02:00"} {"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Acceptance: npm test/typecheck/lint/build pass, provider-specific unit tests prove real serialization/parsing code paths, docs mention OpenAI done in PR #7, and examples/live smokes are opt-in with existing secret conventions.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} {"id":"openhands-agent-w38","title":"P5 — Conversation + agent loop","description":"Conversation + agent loop. Transpile LocalConversation, RemoteConversation, ConversationState, the agent step loop, stuck detection. MUST include the multi-tool-use PENDING-ACTIONS QUEUE: when the LLM emits multiple tool calls, queue them as ActionEvents, execute (incl. parallel via ParallelToolExecutor equivalent), track unmatched actions (get_unmatched_actions), support cancellation/rejection of pending actions. This is core execution machinery (NOT confirmation) and is required. NO confirmation gate. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: Added RemoteConversation REST client slice. RemoteConversation now supports sendMessage without implicit run, run with optional blocking status polling, rejectPendingActions, pause, and interrupt over /api/conversations endpoints, with local executionStatus mirroring server terminal states. Verification after this slice: npm test, typecheck, lint, build pass (112 tests). P5 now covers ConversationState, LocalConversation, RemoteConversation, Agent step loop, stuck detection, pending/unmatched action queue, cancellation/rejection, and multi-tool parallel execution. Commits include 091c900, 38f112f, 56d9fa0, 9666ba6.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.619859+02:00","updated_at":"2026-06-24T05:57:53.318925+02:00","closed_at":"2026-06-24T05:57:53.318925+02:00","dependencies":[{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.105414+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.58951+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-2ba","type":"blocks","created_at":"2026-06-24T01:10:17.664653+02:00","created_by":"daemon"}]} {"id":"openhands-agent-ygp","title":"P1 — Foundations: utils, logger, io, event model","description":"FOUNDATIONS — and the first real code, so it sets the workflow: TESTS + EXAMPLES FIRST (red/green). Port the relevant Python tests to vitest and the examples, watch them fail, then implement. Modules: utils, logger, io, event model (low-dependency leaves). Establishes the zod v4 patterns (pydantic BaseModel -\u003e zod schema + z.infer) and the event discriminated-union shape everything builds on. Serialization round-trip tests against Python JSON fixtures. Public API stays consistent with Python (adapted to TS idioms); clean APIs win. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P1 foundations implemented and verified. Covered zod v4 LLM message/content schemas needed by events; Python-compatible event schemas and eventsToMessages batching/user-message coalescing; ACP tool call and hook execution event parity helpers; utils for async callback wrapping, truncate/path/github/paging/command/redaction/json/datetime/display/deprecated-field handling; LocalFileStore/InMemoryFileStore/MemoryLRUCache; lightweight neutral logger with no Python/LiteLLM-specific default suppression. Secret-handling decision recorded for P2: do NOT port Python Cipher/plaintext/encrypted-at-rest split; implement keyring-backed SecretRef/SecretStore instead. LLM keys are provider-scoped by default under keyring service 'openhands', with explicit per-profile overrides for cases like multiple litellm_proxy profiles using different proxy keys. Verification: npm test, typecheck, lint, and build pass (47 tests).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.396859+02:00","updated_at":"2026-06-24T04:03:13.724783+02:00","closed_at":"2026-06-24T04:03:13.724783+02:00","dependencies":[{"issue_id":"openhands-agent-ygp","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.796883+02:00","created_by":"daemon"}]} diff --git a/docs/NATIVE_TOOLS_RESEARCH.md b/docs/NATIVE_TOOLS_RESEARCH.md new file mode 100644 index 0000000..f621479 --- /dev/null +++ b/docs/NATIVE_TOOLS_RESEARCH.md @@ -0,0 +1,525 @@ +# Native Tool Calling Research + +Research for beads: `openhands-agent-tools-research`, `openhands-agent-tools-native` + +## Summary + +This document captures provider-native tool API shapes for Anthropic Messages, Gemini Interactions API, and OpenAI-compatible clients, along with proven data-shape choices from the old oh-tab implementation. The goal is to wire `ToolDefinition[]` through each provider's `complete()` method. + +## Provider API Shapes + +### Anthropic Messages API + +**Official docs**: https://docs.anthropic.com/en/docs/build-with-claude/tool-use + +#### Request format + +```typescript +{ + model: string; + max_tokens: number; + messages: Message[]; + tools?: Array<{ + name: string; + description?: string; + input_schema: JSONSchema; // OpenAPI 3.0 schema + }>; + tool_choice?: { type: 'auto' | 'any' | 'none' } | { type: 'tool'; name: string }; +} +``` + +**Key points**: +- Tools are top-level array, not nested under `function` +- `input_schema` is the JSON Schema directly (no `parameters` wrapper) +- `tool_choice` defaults to `auto` when tools are provided +- `any` forces a tool call, `none` prohibits tool calls, `tool` forces a specific tool + +#### Response format + +Model returns `tool_use` content blocks: + +```typescript +{ + role: 'assistant'; + content: Array< + | { type: 'text'; text: string } + | { type: 'thinking'; thinking: string; signature?: string } // for extended thinking + | { type: 'tool_use'; id: string; name: string; input: unknown } // parsed JSON args + >; + stop_reason: 'tool_use' | 'end_turn' | ...; +} +``` + +#### Tool result continuation + +Send results back as `tool_result` content in a `user` message: + +```typescript +{ + role: 'user'; + content: [{ + type: 'tool_result'; + tool_use_id: string; + content: string | Array<{ type: 'text'; text: string } | { type: 'image'; source: ... }>; + is_error?: boolean; + }]; +} +``` + +**Important**: +- Previous assistant message with `tool_use` blocks must be included in next turn +- Tool results go in `user` role messages (not `tool` role) +- Multiple tool results can be in a single user message +- `is_error: true` signals tool execution failure + +#### oh-tab reference + +File: `~/repos/oh-tab/packages/agent-sdk/src/sdk/llm/anthropic.ts` + +```typescript +const toAnthropicTools = (tools?: LLMToolDefinition[]): Array<{ + name: string; + description?: string; + input_schema: unknown; +}> | undefined => { + if (!tools?.length) return undefined; + return tools.map((tool) => ({ + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters ?? { type: 'object', properties: {} }, + })); +}; +``` + +Message serialization: +- `tool` role messages → `user` role with `tool_result` content blocks +- `tool_use_id` comes from `message.tool_call_id` +- Assistant messages with `tool_calls` → `tool_use` content blocks +- `input` is parsed JSON from `toolCall.function.arguments` + +### Gemini Interactions API + +**IMPORTANT**: Use the **Interactions API**, not the legacy `generateContent` API. + +**Official docs**: +- Overview: https://ai.google.dev/gemini-api/docs/interactions-overview +- Function calling: https://ai.google.dev/gemini-api/docs/function-calling +- API reference: https://ai.google.dev/api/interactions-api + +#### Request format + +**Endpoint**: `POST /v1beta/interactions` + +```typescript +{ + model: string; // e.g. "gemini-3.6-flash" + input: string | Array; // user prompt or conversation history + tools?: Array<{ + type: 'function'; + name: string; + description: string; + parameters: JSONSchema; // JSON Schema for arguments + }>; + generation_config?: { + tool_choice?: 'auto' | 'any' | 'none'; + temperature?: number; + thinking_level?: string; // for thinking models + // ... other generation params + }; + system_instruction?: string | { parts: Array<{ text: string }> }; + previous_interaction_id?: string; // for stateful multi-turn + store?: boolean; // default true +} +``` + +**Key differences from legacy generateContent**: +- Tools use `type: 'function'` at top level (not nested `functionDeclarations`) +- `parameters` is the schema directly (no extra wrapper) +- `tool_choice` is in `generation_config`, not `toolConfig.functionCallingConfig.mode` +- Returns `Interaction` resource with `steps` array +- Supports stateful conversations via `previous_interaction_id` + +#### Response format + +Returns an `Interaction` object with execution `steps`: + +```typescript +{ + id: string; + steps: Array< + | { type: 'thought'; content: Array<{ type: 'text'; text: string }> } // thinking steps + | { type: 'function_call'; id: string; name: string; arguments: Record } + | { type: 'model_output'; content: Array<{ type: 'text'; text: string }> } + >; + output_text?: string; // convenience field +} +``` + +**Important**: +- `function_call` steps have `id`, `name`, and `arguments` (already parsed JSON object, not string) +- Multiple `function_call` steps can appear (parallel calling) +- `thought` steps contain thinking/reasoning content +- `model_output` steps contain final text response + +#### Tool result continuation + +Send results back as `function_result` input: + +```typescript +{ + model: string; + input: [{ + type: 'function_result'; + name: string; // function name + call_id: string; // from function_call step + result: Array<{ type: 'text'; text: string }>; // serialized result + }]; + tools: [...]; // must re-send tools + previous_interaction_id: string; // link to previous interaction +} +``` + +**Stateful vs stateless**: +- **Stateful** (recommended): use `previous_interaction_id` to continue conversation, server manages history +- **Stateless**: set `store: false`, send full conversation history in `input` array + +#### Thought signatures + +Gemini 3.x models support `thoughtSignature` for verifiable thinking: +- SDK automatically includes signature in request if present in previous messages +- Signature appears in `thought` steps +- Already verified in this repo: `thinkingConfig.thinkingLevel` for Gemini 3.x + +#### oh-tab reference (legacy generateContent, DO NOT port directly) + +File: `~/repos/oh-tab/packages/agent-sdk/src/sdk/llm/gemini.ts` + +The old implementation uses `generateContent` API: + +```typescript +const toGeminiTools = (tools): GeminiGenerateContentRequest['tools'] | undefined => { + if (!tools?.length) return undefined; + const functionDeclarations = tools.map((tool) => ({ + name: tool.function.name, + description: tool.function.description, + parameters: stripUnsupportedSchemaProps(tool.function.parameters), + })); + return [{ functionDeclarations }]; // legacy nested shape +}; +``` + +**DO NOT USE THIS SHAPE**: The Interactions API uses a flat `tools` array with `type: 'function'`. + +Response parsing (still relevant for understanding): +- Function calls appear as `functionCall` parts in content +- Arguments are objects, not strings +- Tool results use `functionResponse` parts with `name` and `response` + +### OpenAI-compatible + +Already implemented in PR #7 for native OpenAI clients. The question is how to handle proxies like OpenRouter, LiteLLM-compatible servers, etc. + +#### Strategy + +1. **Reuse OpenAI Chat Completions shape** for most OpenAI-compatible providers +2. **Gate by `providerId`** for known quirks (e.g., OpenRouter might have different behavior) +3. **Document unsupported cases** clearly +4. **Do NOT add LiteLLM dependency** - stick to the four-client architecture + +#### Current OpenAI implementation reference + +`OpenAIChatClient` already wraps `ToolDefinition.toResponsesTool()` in the nested function shape: + +```typescript +function toOpenAIChatTool(tool: ToolDefinition): Record { + const responsesTool = tool.toResponsesTool(); + return { + type: 'function', + function: { + name: responsesTool.name, + description: responsesTool.description, + parameters: responsesTool.parameters, + strict: responsesTool.strict, + }, + }; +} +``` + +Tool calls in responses: + +```typescript +{ + message: { + tool_calls?: Array<{ + id: string; + type: 'function'; + function: { name: string; arguments: string }; // JSON string + }>; + }; +} +``` + +Tool results in continuation: + +```typescript +{ + role: 'tool'; + tool_call_id: string; + name?: string; + content: string; +} +``` + +## Implementation Plan + +### Phase 1: Anthropic native tools + +**Bead**: `openhands-agent-tools-anthropic` + +1. **Update `AnthropicMessagesClient.complete()` signature** + - Add `tools?: readonly ToolDefinition[]` parameter + - Keep parameter optional for backward compatibility + +2. **Request serialization** + - Create `toAnthropicTool(tool: ToolDefinition)` function: + ```typescript + { + name: tool.name, + description: tool.description, + input_schema: tool.toResponsesTool().parameters, // JSON Schema + } + ``` + - Add tools array to request body when `tools.length > 0` + - Omit `tools` field when array is empty (don't send `tools: []`) + - Default `tool_choice: { type: 'auto' }` when tools are present + +3. **Response parsing** + - `parseAnthropicMessagesResponse()` already handles `tool_use` blocks in content + - Extract `tool_use` blocks → `MessageToolCall[]` + - Parse `input` field as JSON for `arguments` string + - Use `id` from `tool_use` block + +4. **Message continuation serialization** + - `toAnthropicMessage()` already handles `message.tool_calls` → `tool_use` blocks (lines 128-130) + - `toAnthropicMessage()` already handles `role: 'tool'` → `tool_result` blocks (lines 106-108, 143-150) + - Verify `tool_use_id` mapping from `message.tool_call_id` + - Verify `input` serialization from `toolCall.arguments` string + +5. **Tests** + - Unit test: request serialization with tools + - Unit test: request serialization with empty tools array (should omit field) + - Unit test: response parsing with `tool_use` blocks + - Unit test: tool result continuation message serialization + - Unit test: error handling (invalid tool arguments) + - Integration test: full round-trip with mock fetch + - **NO LIVE TEST** per user instructions (Anthropic key has no billing) + +6. **Edge cases from oh-tab** + - Tool arguments parsing: try JSON parse, fall back to raw if invalid + - Empty/missing descriptions: handle gracefully + - Tool choice: support `auto`, `any`, `none`, and specific tool selection + +### Phase 2: Gemini Interactions API native tools + +**Bead**: `openhands-agent-tools-gemini` + +**IMPORTANT**: Migrate `GeminiClient` to the Interactions API, don't add tools to the old `generateContent` implementation. + +1. **Update `GeminiClient` to use Interactions API** + - Change endpoint from `/models/${model}:generateContent` to `/interactions` + - Update request body structure to Interactions format + - Update response parsing to handle `Interaction.steps[]` + - Preserve `thoughtSignature` round-trip for Gemini 3.x + +2. **Add `tools` parameter to `complete()`** + - Add `tools?: readonly ToolDefinition[]` parameter + +3. **Request serialization** + - Convert `messages` → `input` (may need new format for Interactions API) + - System messages → `system_instruction` + - Create `toGeminiInteractionsTool(tool: ToolDefinition)`: + ```typescript + { + type: 'function', + name: tool.name, + description: tool.description, + parameters: tool.toResponsesTool().parameters, + } + ``` + - Add `generation_config.tool_choice: 'auto'` when tools are present + - Preserve `thinkingConfig.thinkingLevel` for Gemini 3.x + +4. **Response parsing** + - Parse `steps` array for `function_call` steps + - Extract `{ id, name, arguments }` from function_call steps + - Note: `arguments` is already a parsed object, not a string + - Convert to `MessageToolCall[]` + - Collect `thought` steps → `reasoning_content` + - Collect `model_output` steps → message content + +5. **Continuation serialization** + - Use `previous_interaction_id` for stateful conversations + - Convert `tool` role messages → `function_result` input items: + ```typescript + { + type: 'function_result', + name: message.name ?? 'unknown_tool', + call_id: message.tool_call_id ?? '', + result: [{ type: 'text', text: contentToString(message.content).join('\n') }], + } + ``` + - Re-send tools in continuation request + +6. **Tests** + - Unit test: Interactions API request format with tools + - Unit test: tool-less request format + - Unit test: function_call step parsing + - Unit test: thought step integration with tool calls + - Unit test: function_result continuation format + - Unit test: thoughtSignature preservation + - Live test: use `GEMINI_API_KEY` with newest Flash model (per user preference) + - Verify working Gemini models via Interactions API (user mentioned `gemini-3.6-flash`, `gemini-3.5-flash`) + +7. **Edge cases** + - Arguments already objects, not strings (no JSON parse needed) + - Multiple parallel function calls in same response + - Mixing thought steps and function calls + - Stateless mode: manage full conversation history client-side + +### Phase 3: OpenAI-compatible client tool propagation + +**Bead**: `openhands-agent-tools-openai-compatible` + +1. **Audit existing OpenAIChatClient tool support** + - Already implemented in `buildChatCompletionsBody()` (lines 140-170) + - Already handles `tools.map(toOpenAIChatTool)` + - Already omits `tools` field when empty + +2. **Provider-specific gating** + - OpenRouter: verify compatibility, add tests + - Custom `baseUrl` proxies: document that they must be OpenAI-compatible + - Add provider quirks if needed in `provider-quirks.ts` + +3. **Tests** + - Verify existing OpenAI Chat tests pass + - Add OpenRouter-specific test cases if needed + - Document unsupported providers in comments/docs + +4. **Documentation** + - Update README with OpenAI-compatible tool calling notes + - Document known-working proxies (OpenRouter, etc.) + - Document limitations for non-standard proxies + +### Phase 4: Cross-provider validation + +**Bead**: `openhands-agent-tools-validation` + +1. **Integration tests** + - Add cross-provider tool calling test suite + - Verify all providers handle same `ToolDefinition` correctly + - Test parallel tool calls (Anthropic, Gemini support this) + - Test error cases (invalid arguments, missing tools, etc.) + +2. **Live examples** + - Update `examples/native-openai-tools.ts` to cover OpenAI Responses (already exists) + - Add `examples/native-anthropic-tools.ts` (credential-gated) + - Add `examples/native-gemini-tools.ts` (use live key) + - Add `examples/cross-provider-tools.ts` showing profile switching + +3. **Documentation** + - Update `docs/ARCHITECTURE.md` with tool calling details + - Add `docs/TOOL_CALLING.md` guide + - Document tool choice modes per provider + - Document parallel tool use support + - Update README with tool calling overview + +4. **CI adjustments** + - Keep Anthropic live tests credential-gated (skip if no key) + - Add Gemini live test (key exists per user) + - Normal CI must not require live provider keys (use mocked tests) + +## Key Architectural Decisions + +1. **Tool definition source**: `ToolDefinition.toResponsesTool()` is the canonical schema source + - All providers derive from this method + - No parallel tool DTO layer + - Provider clients own wire-format wrapping + +2. **Optional tools parameter**: Keep `tools` parameter optional in all `complete()` signatures + - Backward compatible with existing non-tool usage + - Omit wire-level field when empty, don't send `tools: []` + +3. **No LiteLLM dependency**: Stick to the four-client architecture + - OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini (Interactions) + - OpenRouter and compatible proxies use OpenAI Chat shape + - Document unsupported providers clearly + +4. **Preserve existing behaviors**: + - Anthropic extended thinking round-trip + - Gemini `thoughtSignature` for Gemini 3.x + - OpenAI Responses reasoning content + - All existing tests must continue to pass + +5. **Gemini migration priority**: Move Gemini to Interactions API during tool work + - The old `generateContent` API is legacy + - Interactions is GA and recommended by Google + - Combine migration with tool implementation + - Preserve backward compatibility where possible + +## Provider Comparison Table + +| Feature | Anthropic | Gemini Interactions | OpenAI Chat | +|---------|-----------|---------------------|-------------| +| Tool definition shape | `{ name, description, input_schema }` | `{ type: 'function', name, description, parameters }` | `{ type: 'function', function: { name, description, parameters, strict } }` | +| Response tool call | `tool_use` content block | `function_call` step | `tool_calls[]` in message | +| Tool call ID | `id` in tool_use | `id` in function_call | `id` in tool_call | +| Arguments format | Parsed object (`input`) | Parsed object (`arguments`) | JSON string (`arguments`) | +| Tool result role | `user` with `tool_result` | `function_result` input item | `tool` role message | +| Tool result ID field | `tool_use_id` | `call_id` | `tool_call_id` | +| Parallel calls | Yes | Yes | Yes | +| Tool choice modes | `auto`, `any`, `none`, `{ type: 'tool', name }` | `auto`, `any`, `none` (in generation_config) | `auto`, `none`, `{ type: 'function', function: { name } }` | +| Thinking integration | Extended thinking blocks | Thought steps + signatures | Reasoning in Responses API | + +## Testing Strategy + +1. **Unit tests** (all providers): + - Request serialization with tools + - Request serialization without tools (omit field) + - Response parsing with tool calls + - Tool result continuation format + - Error handling + +2. **Integration tests** (mocked fetch): + - Full round-trip: request → tool call → result → final response + - Parallel tool calls + - Thinking/reasoning + tool calls + +3. **Live tests** (credential-gated): + - Anthropic: **skip** (no billing, per user instructions) + - Gemini: **include** (key works, use newest Flash) + - OpenAI: already covered + +4. **Example scripts**: + - One per provider + - Cross-provider comparison example + - All examples skip gracefully if key missing + +## References + +- Anthropic Tool Use: https://docs.anthropic.com/en/docs/build-with-claude/tool-use +- Gemini Interactions API: https://ai.google.dev/gemini-api/docs/interactions-overview +- Gemini Function Calling: https://ai.google.dev/gemini-api/docs/function-calling +- Gemini API Reference: https://ai.google.dev/api/interactions-api +- Gemini Migration Guide: https://ai.google.dev/gemini-api/docs/migrate-to-interactions +- oh-tab reference: `~/repos/oh-tab/packages/agent-sdk/src/sdk/llm/{anthropic,gemini}.ts` +- OpenAI tools already merged: PR #7 + +## Next Steps + +1. Mark research bead (`openhands-agent-tools-research`) as done +2. Start with `openhands-agent-tools-anthropic` +3. Continue with `openhands-agent-tools-gemini` (includes Interactions migration) +4. Quick pass on `openhands-agent-tools-openai-compatible` (mostly docs) +5. Finish with `openhands-agent-tools-validation` (tests, examples, docs) + +Update bead statuses and commit `.beads/issues.jsonl` after each phase. From 09344cd41e8d9d5709f00bd17f142125c2c7294f Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Fri, 31 Jul 2026 20:49:49 +0200 Subject: [PATCH 2/7] feat: Anthropic native tool calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete bead openhands-agent-tools-anthropic. Implemented native tool calling for AnthropicMessagesClient: - Added tools parameter to AnthropicMessagesClient.complete() - Added toAnthropicTool() to serialize ToolDefinition to Anthropic format: {name, description, input_schema} - buildAnthropicMessagesBody() now adds tools array and tool_choice: auto - Added anthropicToolUseBlockSchema to response parsing - parseAnthropicMessagesResponse() extracts tool_use blocks → MessageToolCall[] - fromAnthropicToolUse() converts tool_use to MessageToolCall with JSON serialized arguments - Tool result continuation already worked via existing toAnthropicMessage() Tests (11 total, all pass): - Request serialization with tools (native format) - No-tools omission (don't send empty tools array) - tool_use block parsing into MessageToolCall - Parallel tool calls handling - Tool result continuation serialization - Invalid tool arguments gracefully handled (fallback to string) All 250 tests pass. No live Anthropic API calls per instructions. Co-authored-by: smolpaws --- .beads/issues.jsonl | 2 +- src/llm/__tests__/anthropic-client.test.ts | 155 ++++++++++++++++++++- src/llm/anthropic.ts | 39 +++++- 3 files changed, 189 insertions(+), 7 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4113fcd..f526492 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -27,7 +27,7 @@ {"id":"openhands-agent-kx8.6","title":"Add no-op-safe TS observability wrapper","description":"Read Python observability/laminar.py and utils.py. Add idiomatic TS wrapper compatible with standard JS OpenTelemetry and, if practical, Laminar. It must be no-op when env vars are absent. Add tests first for env gating, no-op behavior, and action-name helpers.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:45:02.445219+02:00","updated_at":"2026-06-26T06:02:30.884898+02:00","closed_at":"2026-06-26T06:02:30.884898+02:00","labels":["observability","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.6","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:45:02.445781+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.6","depends_on_id":"openhands-agent-kx8.2","type":"blocks","created_at":"2026-06-26T05:45:02.446871+02:00","created_by":"daemon"}]} {"id":"openhands-agent-kx8.7","title":"Expand applicable Python tests and examples coverage","description":"Port applicable Python examples/tests after underlying gaps land: persistence, async send-message-while-running, condenser, remote conversation, workspace, extensions, observability, testing helpers, and wire restore. Examples workflow remains manual or test-examples label only.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:45:12.196265+02:00","updated_at":"2026-06-26T06:02:59.14236+02:00","closed_at":"2026-06-26T06:02:59.14236+02:00","labels":["examples","tests","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:45:12.196889+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.3","type":"blocks","created_at":"2026-06-26T05:45:12.198157+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.4","type":"blocks","created_at":"2026-06-26T05:45:12.198771+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.5","type":"blocks","created_at":"2026-06-26T05:45:12.199328+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.6","type":"blocks","created_at":"2026-06-26T05:45:12.199858+02:00","created_by":"daemon"}]} {"id":"openhands-agent-mvm","title":"Fix examples GitHub environment OPENAI_API_KEY","description":"Manual examples workflow on main at e301a19 reached the real OpenAI profile path, but GitHub Actions failed with OpenAI HTTP 401 invalid_api_key. Code/local live run succeeded with the injected local credential, so the GitHub examples environment secret likely needs updating.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-07-05T08:36:48.625798+02:00","updated_at":"2026-07-06T05:32:50.995031+02:00","closed_at":"2026-07-06T05:32:50.995031+02:00","labels":["ci","examples","secrets"]} -{"id":"openhands-agent-tools-anthropic","title":"Implement Anthropic native tool calling","description":"Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.","notes":"Acceptance: unit tests cover request tool serialization, no-tools omission, tool_use response parsing, tool_result continuation serialization, unknown/invalid argument behavior through existing dispatch, and no regression in existing Anthropic tests. Add a live smoke command only if credentials are already supported by the repo workflow.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["anthropic","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id": "openhands-agent-tools-anthropic", "title": "Implement Anthropic native tool calling", "description": "Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.", "notes": "Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions.", "status": "closed", "priority": 1, "issue_type": "task", "created_at": "2026-07-15T01:12:47.261769+02:00", "updated_at": "2026-07-31T20:49:33.806344+02:00", "labels": ["anthropic", "llm", "tools", "transpile"], "dependencies": [{"issue_id": "openhands-agent-tools-anthropic", "depends_on_id": "openhands-agent-tools-native", "type": "parent-child", "created_at": "2026-07-15T01:12:47.261769+02:00", "created_by": "openhands"}, {"issue_id": "openhands-agent-tools-anthropic", "depends_on_id": "openhands-agent-tools-research", "type": "blocks", "created_at": "2026-07-15T01:12:47.261769+02:00", "created_by": "openhands"}], "closed_at": "2026-07-31T20:49:33.806344+02:00"} {"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Acceptance: unit tests cover Interactions request tool declarations, no-tools omission, function-call parsing, function-response/tool-result continuation, coexistence with thought signatures, and no regression in existing Gemini tests. Use current official Google Interactions API docs as the authority.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} {"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"OpenAI native tool passing is already done by PR #7; this epic tracks the remaining provider clients only.","status":"open","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","tools","transpile"]} {"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Acceptance: tests cover OpenAI-compatible chat payload tools, no-tools omission, custom baseUrl/proxy behavior, OpenRouter behavior if supported, and clear docs for any disabled or unverified provider dialect.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} diff --git a/src/llm/__tests__/anthropic-client.test.ts b/src/llm/__tests__/anthropic-client.test.ts index a7bf0ba..882e0f2 100644 --- a/src/llm/__tests__/anthropic-client.test.ts +++ b/src/llm/__tests__/anthropic-client.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { InMemorySecretStore, llmProviderSecretRef } from '../../secrets/index.js'; +import { ToolDefinition } from '../../tool/index.js'; import { textContent } from '../index.js'; import { AnthropicMessagesClient, buildAnthropicMessagesBody, createAnthropicClientFromProfile, llmProfileSchema } from '../anthropic.js'; @@ -108,26 +110,175 @@ describe('profile-resolved Anthropic Messages client', () => { }); }); +describe('Anthropic native tool calling', () => { + const testTool = new ToolDefinition({ + name: 'get_weather', + description: 'Get the current weather for a location', + inputSchema: z.object({ location: z.string() }), + executor: async () => ({ content: 'sunny' }), + }); + + it('serializes tools to Anthropic native format', () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const messages = [{ role: 'user' as const, content: [textContent('What is the weather?')] }]; + + const body = buildAnthropicMessagesBody(profile, messages, [testTool]); + + expect(body.tools).toMatchObject([ + { + name: 'get_weather', + description: 'Get the current weather for a location', + input_schema: { type: 'object', properties: { location: { type: 'string' } }, required: ['location'] }, + }, + ]); + expect(body.tool_choice).toEqual({ type: 'auto' }); + }); + + it('omits tools field when no tools are provided', () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const messages = [{ role: 'user' as const, content: [textContent('Hello')] }]; + + const body = buildAnthropicMessagesBody(profile, messages, []); + + expect(body).not.toHaveProperty('tools'); + expect(body).not.toHaveProperty('tool_choice'); + }); + + it('parses tool_use blocks into MessageToolCall records', async () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const store = new InMemorySecretStore([[llmProviderSecretRef('anthropic'), 'anthropic-key']]); + const client = await createAnthropicClientFromProfile( + profile, + store, + { + fetch: fakeAnthropicFetch({ + content: [ + { type: 'text', text: 'Let me check the weather.' }, + { type: 'tool_use', id: 'toolu_01A', name: 'get_weather', input: { location: 'San Francisco' } }, + ], + }), + }, + ); + + const result = await client.complete([{ role: 'user', content: [textContent('What is the weather in SF?')] }], [testTool]); + + expect(result.message.role).toBe('assistant'); + expect(result.message.content).toEqual([textContent('Let me check the weather.')]); + expect(result.message.tool_calls).toEqual([ + { + id: 'toolu_01A', + responses_item_id: null, + name: 'get_weather', + arguments: '{"location":"San Francisco"}', + origin: 'completion', + }, + ]); + }); + + it('handles multiple parallel tool calls', async () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const store = new InMemorySecretStore([[llmProviderSecretRef('anthropic'), 'anthropic-key']]); + const secondTool = new ToolDefinition({ + name: 'get_time', + description: 'Get the current time', + inputSchema: z.object({}), + executor: async () => ({ content: '12:00' }), + }); + const client = await createAnthropicClientFromProfile( + profile, + store, + { + fetch: fakeAnthropicFetch({ + content: [ + { type: 'tool_use', id: 'toolu_01A', name: 'get_weather', input: { location: 'NYC' } }, + { type: 'tool_use', id: 'toolu_01B', name: 'get_time', input: {} }, + ], + }), + }, + ); + + const result = await client.complete([{ role: 'user', content: [textContent('Weather and time?')] }], [testTool, secondTool]); + + expect(result.message.tool_calls).toHaveLength(2); + expect(result.message.tool_calls?.[0]?.name).toBe('get_weather'); + expect(result.message.tool_calls?.[1]?.name).toBe('get_time'); + }); + + it('serializes tool result continuation correctly', () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const messages = [ + { role: 'user' as const, content: [textContent('What is the weather?')] }, + { + role: 'assistant' as const, + content: [textContent('Let me check.')], + tool_calls: [{ id: 'toolu_01A', responses_item_id: null, name: 'get_weather', arguments: '{"location":"SF"}', origin: 'completion' as const }], + }, + { role: 'tool' as const, tool_call_id: 'toolu_01A', name: 'get_weather', content: [textContent('72°F and sunny')] }, + ]; + + const body = buildAnthropicMessagesBody(profile, messages, [testTool]); + + expect(body.messages).toHaveLength(3); + expect(body.messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'text', text: 'Let me check.' }, + { type: 'tool_use', id: 'toolu_01A', name: 'get_weather', input: { location: 'SF' } }, + ], + }); + expect(body.messages[2]).toEqual({ + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_01A', content: '72°F and sunny' }], + }); + }); + + it('handles invalid tool arguments gracefully', () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const messages = [ + { + role: 'assistant' as const, + content: [], + tool_calls: [{ id: 'toolu_01A', responses_item_id: null, name: 'get_weather', arguments: 'not valid json', origin: 'completion' as const }], + }, + ]; + + const body = buildAnthropicMessagesBody(profile, messages, [testTool]); + + expect(body.messages[0]?.content).toContainEqual({ + type: 'tool_use', + id: 'toolu_01A', + name: 'get_weather', + input: 'not valid json', + }); + }); +}); + interface FakeFetchCall { readonly url: string; readonly headers: Record; readonly body: Record; } -function fakeAnthropicFetch(response: { text: string }, calls: FakeFetchCall[] = []) { +type AnthropicContentBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: unknown } + | { type: 'thinking'; thinking: string; signature?: string }; + +function fakeAnthropicFetch(response: { text: string } | { content: readonly AnthropicContentBlock[] }, calls: FakeFetchCall[] = []) { return async (url: string, init: { headers: Readonly>; body: string }) => { calls.push({ url, headers: normalizeHeaders(init.headers), body: JSON.parse(init.body) as Record, }); + const content = 'text' in response ? [{ type: 'text' as const, text: response.text }] : response.content; return { ok: true, status: 200, async json() { return { role: 'assistant', - content: [{ type: 'text', text: response.text }], + content, usage: { input_tokens: 11, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, }; }, diff --git a/src/llm/anthropic.ts b/src/llm/anthropic.ts index 7d9825c..147b4c8 100644 --- a/src/llm/anthropic.ts +++ b/src/llm/anthropic.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { getLlmApiKey } from '../secrets/index.js'; import type { SecretStore } from '../secrets/index.js'; +import type { ToolDefinition } from '../tool/index.js'; import { llmCompletionResponseSchema, type FetchLike, type LLMClient, type LLMCompletionResponse } from './client.js'; import { contentToString, messageSchema, reduceTextContent, type Content, type LLMProfile, type Message, type MessageToolCall } from './index.js'; import { getAnthropicThinkingBudget, normalizeGenerationParamsForModel, supportsPromptCaching } from './provider-quirks.js'; @@ -28,8 +29,8 @@ export class AnthropicMessagesClient implements LLMClient { this.fetchImpl = fetchImpl; } - async complete(messages: readonly Message[]): Promise { - const body = buildAnthropicMessagesBody(this.profile, messages); + async complete(messages: readonly Message[], tools?: readonly ToolDefinition[]): Promise { + const body = buildAnthropicMessagesBody(this.profile, messages, tools); const response = await this.fetchImpl(`${resolveBaseUrl(this.profile)}/v1/messages`, { method: 'POST', headers: buildHeaders(this.profile, this.apiKey), @@ -66,7 +67,7 @@ export async function createAnthropicClientFromProfile( return new AnthropicMessagesClient(profile, apiKey, options.fetch ?? defaultFetch); } -export function buildAnthropicMessagesBody(profile: LLMProfile, messages: readonly Message[]): Record { +export function buildAnthropicMessagesBody(profile: LLMProfile, messages: readonly Message[], tools?: readonly ToolDefinition[]): Record { const normalizedProfile = normalizeGenerationParamsForModel(profile); const parsedMessages = messages.map((message) => messageSchema.parse(message)); const systemMessages = parsedMessages.filter((message) => message.role === 'system'); @@ -84,6 +85,10 @@ export function buildAnthropicMessagesBody(profile: LLMProfile, messages: readon ? [{ type: 'text', text: system.join('\n'), cache_control: { type: 'ephemeral' } }] : system.join('\n'); } + if (tools && tools.length > 0) { + body.tools = tools.map(toAnthropicTool); + body.tool_choice = { type: 'auto' }; + } if (normalizedProfile.temperature !== null) { body.temperature = normalizedProfile.temperature; } @@ -99,6 +104,15 @@ export function buildAnthropicMessagesBody(profile: LLMProfile, messages: readon return body; } +function toAnthropicTool(tool: ToolDefinition): Record { + const responsesTool = tool.toResponsesTool(); + return { + name: responsesTool.name, + description: responsesTool.description, + input_schema: responsesTool.parameters, + }; +} + function toAnthropicMessage(profile: LLMProfile, message: Message): Record { if (message.role === 'assistant') { return { role: 'assistant', content: toAnthropicAssistantContent(message) }; @@ -181,11 +195,14 @@ function parseAnthropicMessagesResponse(raw: unknown): LLMCompletionResponse { .join('\n'); const thinkingBlocks = parsed.content.filter((block): block is AnthropicThinkingBlock => block.type === 'thinking'); const reasoningContent = thinkingBlocks.map((block) => block.thinking).join(''); + const toolUseBlocks = parsed.content.filter((block): block is AnthropicToolUseBlock => block.type === 'tool_use'); + const toolCalls = toolUseBlocks.map(fromAnthropicToolUse); return llmCompletionResponseSchema.parse({ message: { role: 'assistant', content: text, + tool_calls: toolCalls.length > 0 ? toolCalls : null, reasoning_content: reasoningContent.length > 0 ? reasoningContent : null, thinking_blocks: thinkingBlocks.map((block) => ({ type: 'thinking', @@ -202,6 +219,16 @@ function parseAnthropicMessagesResponse(raw: unknown): LLMCompletionResponse { }); } +function fromAnthropicToolUse(block: AnthropicToolUseBlock): MessageToolCall { + return { + id: block.id, + responses_item_id: null, + name: block.name, + arguments: JSON.stringify(block.input), + origin: 'completion', + }; +} + function resolveBaseUrl(profile: LLMProfile): string { return (profile.baseUrl ?? DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/u, ''); } @@ -226,11 +253,15 @@ const anthropicTextBlockSchema = z.object({ type: z.literal('text'), text: z.str const anthropicThinkingBlockSchema = z .object({ type: z.literal('thinking'), thinking: z.string(), signature: z.string().nullable().optional() }) .passthrough(); +const anthropicToolUseBlockSchema = z + .object({ type: z.literal('tool_use'), id: z.string(), name: z.string(), input: z.unknown() }) + .passthrough(); const anthropicOtherBlockSchema = z.object({ type: z.string() }).passthrough(); -const anthropicContentBlockSchema = z.union([anthropicTextBlockSchema, anthropicThinkingBlockSchema, anthropicOtherBlockSchema]); +const anthropicContentBlockSchema = z.union([anthropicTextBlockSchema, anthropicThinkingBlockSchema, anthropicToolUseBlockSchema, anthropicOtherBlockSchema]); type AnthropicTextBlock = z.infer; type AnthropicThinkingBlock = z.infer; +type AnthropicToolUseBlock = z.infer; const anthropicMessagesResponseSchema = z .object({ From 41c9e4667de729464f172d38535933003a93c8a3 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Fri, 31 Jul 2026 21:14:25 +0200 Subject: [PATCH 3/7] feat: use Gemini Interactions for native tools Move GeminiClient to the current stateless Interactions API so the durable SDK transcript remains the conversation source of truth. Serialize ToolDefinition schemas as flat function tools, replay signed thoughts and function steps, parse parallel calls, and return function results in native form. Add focused request/response tests and a credential-gated live example. A real gemini-3.5-flash-lite run dispatched lookup_value and finish successfully; all 254 tests plus typecheck, lint, build, and example typecheck pass. Co-authored-by: smolpaws Co-authored-by: openhands --- .beads/issues.jsonl | 26 +- examples/native-gemini-tools.ts | 65 +++++ package.json | 1 + src/llm/__tests__/factory.test.ts | 9 +- src/llm/__tests__/gemini-client.test.ts | 199 +++++++++----- src/llm/gemini.ts | 340 +++++++++++++++--------- 6 files changed, 424 insertions(+), 216 deletions(-) create mode 100644 examples/native-gemini-tools.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f526492..d6ecf0a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,21 +2,21 @@ {"id":"openhands-agent-0sl","title":"Audit Python SDK test and example parity","description":"Compare relevant Python agent-sdk tests and examples against TypeScript transpile coverage, excluding deliberate divergences (ACP runtime, confirmation/security analyzer execution, old SecretRegistry runtime). Add focused parity tests/examples for implemented relevant surfaces.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T02:42:26.577102+02:00","updated_at":"2026-07-05T02:46:49.449825+02:00","closed_at":"2026-07-05T02:46:49.449825+02:00","labels":["examples","tests","transpile"]} {"id":"openhands-agent-11i","title":"Port live provider API scripts","description":"Port legacy live scripts for OpenAI Responses reasoning round-trip and Anthropic prompt-caching smoke into this package with env-key skips, provider-keyed InMemorySecretStore, safe artifacts, and provider-format coverage.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-06T04:21:27.907958+02:00","updated_at":"2026-07-06T04:24:29.447073+02:00","closed_at":"2026-07-06T04:24:29.447073+02:00","labels":["live-tests","llm","providers"]} {"id":"openhands-agent-1sj","title":"Port RemoteWorkspace against local Python agent-server","description":"Implement TypeScript RemoteWorkspace parity using a real local Python SDK agent-server for tests, not mocked HTTP. Read Python remote workspace sources first, add red integration tests, then implement green.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T06:45:20.798424+02:00","updated_at":"2026-06-26T06:57:40.459403+02:00","closed_at":"2026-06-26T06:57:40.459403+02:00","labels":["remote","transpile","workspace"]} -{"id":"openhands-agent-2ba","title":"P4 — LLM layer (thin abstraction, fat clients)","description":"LLM layer is PROFILE-FIRST. LLM is used ONLY via LLM profiles — there is no bare/standalone LLM entry point in the public API. No model fallback chains, no implicit default model, nothing — just profiles. Profiles resolve provider API keys through SecretRef/keyring, never embedded raw values. Key lookup is provider-driven, not model-family-driven: explicit per-profile override first when enabled and present, otherwise provider key by providerId (e.g. litellm_proxy uses llm-provider:litellm_proxy even when the model string looks like OpenAI/Anthropic/Gemini). LLMClient is a deliberately THIN interface; most logic lives inside each client (do NOT over-abstract). The 4 clients sit BEHIND profile resolution, never exposed bare; build one at a time end-to-end: OpenAI/OpenAI-compatible, Anthropic Messages, Gemini interactions, OpenAI Responses. In-repo live-test scripts under scripts/live/ (NOT CI) using a GitHub environment named 'llm'. Minimal shared interface extracted LAST. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P4 LLM layer implemented profile-first. Added thin shared LLMClient contract extracted after clients, plus four profile-resolved clients behind LLMProfile + SecretStore: OpenAI/OpenAI-compatible chat completions, Anthropic Messages, Gemini generateContent, and OpenAI Responses. API keys are resolved from OS-keyring-compatible SecretStore by providerId/profile override; no raw secrets are embedded in profiles/settings. Provider lookup remains providerId-driven, not model-family-driven (e.g. litellm_proxy key for openai-looking model strings). Added keyring-only scripts/live/llm-smoke.mjs and npm run live:llm for non-CI live checks. Verification: node --check scripts/live/llm-smoke.mjs, npm test, typecheck, lint, and build pass (89 tests). Commits: 48f6a5c, 2333ed0, 84b5ff8, 58a42e8, bbee181, 57f7b63.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.567032+02:00","updated_at":"2026-06-24T05:21:05.440351+02:00","closed_at":"2026-06-24T05:21:05.440351+02:00","dependencies":[{"issue_id":"openhands-agent-2ba","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.052607+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-2ba","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.503792+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-2ba","title":"P4 \u2014 LLM layer (thin abstraction, fat clients)","description":"LLM layer is PROFILE-FIRST. LLM is used ONLY via LLM profiles \u2014 there is no bare/standalone LLM entry point in the public API. No model fallback chains, no implicit default model, nothing \u2014 just profiles. Profiles resolve provider API keys through SecretRef/keyring, never embedded raw values. Key lookup is provider-driven, not model-family-driven: explicit per-profile override first when enabled and present, otherwise provider key by providerId (e.g. litellm_proxy uses llm-provider:litellm_proxy even when the model string looks like OpenAI/Anthropic/Gemini). LLMClient is a deliberately THIN interface; most logic lives inside each client (do NOT over-abstract). The 4 clients sit BEHIND profile resolution, never exposed bare; build one at a time end-to-end: OpenAI/OpenAI-compatible, Anthropic Messages, Gemini interactions, OpenAI Responses. In-repo live-test scripts under scripts/live/ (NOT CI) using a GitHub environment named 'llm'. Minimal shared interface extracted LAST. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P4 LLM layer implemented profile-first. Added thin shared LLMClient contract extracted after clients, plus four profile-resolved clients behind LLMProfile + SecretStore: OpenAI/OpenAI-compatible chat completions, Anthropic Messages, Gemini generateContent, and OpenAI Responses. API keys are resolved from OS-keyring-compatible SecretStore by providerId/profile override; no raw secrets are embedded in profiles/settings. Provider lookup remains providerId-driven, not model-family-driven (e.g. litellm_proxy key for openai-looking model strings). Added keyring-only scripts/live/llm-smoke.mjs and npm run live:llm for non-CI live checks. Verification: node --check scripts/live/llm-smoke.mjs, npm test, typecheck, lint, and build pass (89 tests). Commits: 48f6a5c, 2333ed0, 84b5ff8, 58a42e8, bbee181, 57f7b63.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.567032+02:00","updated_at":"2026-06-24T05:21:05.440351+02:00","closed_at":"2026-06-24T05:21:05.440351+02:00","dependencies":[{"issue_id":"openhands-agent-2ba","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.052607+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-2ba","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.503792+02:00","created_by":"daemon"}]} {"id":"openhands-agent-2fh","title":"Decide smolpaws conversationRuntime deviation seam","description":"Assess conversationRuntime deviations from sdk-swap map: SecretRegistry to SecretStore, dropped security/confirmation surfaces, and clearRawLlmFieldsWhenProfileSelected. Implement package-level helpers/tests where the transpile should own them; document conscious drop decisions in code/tests rather than silently reintroducing old surfaces.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T01:54:35.738815+02:00","updated_at":"2026-07-05T01:56:57.197665+02:00","closed_at":"2026-07-05T01:56:57.197665+02:00","labels":["interop","secrets","settings","smolpaws"]} -{"id":"openhands-agent-2rb","title":"P2 — Types \u0026 settings models","description":"Transpile settings models and profiles, including SecretRef and a keyring-backed SecretStore abstraction. Do NOT port Python's Cipher, local plaintext secret persistence, or docker/remote/agent-server encrypted-at-rest branching. Settings/profiles persist secret references only. Raw secret values live in OS keyring service 'openhands'. LLM API keys are provider-scoped by default using accounts like llm-provider:\u003cproviderId\u003e, with explicit per-profile override accounts like llm-profile:\u003cprofileId\u003e:api-key for cases where the same provider needs a second credential (for example app/eval litellm_proxy profiles). Pure-data; validates the zod approach at scale. Serialization round-trip tests. Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: P2 in progress. Implemented keyring-backed secret references, raw-secret-free LLM profiles, secret-free AgentProfile schemas, and profile-first settings schemas. Secret work: SecretRef serializes only {service, account}; InMemorySecretStore for tests; MacOSKeychainSecretStore via macOS security CLI; provider/profile LLM refs and resolution semantics. LLM profile schema covers providerId/model/baseUrl/generation params/headers/useProfileKeyOverride and rejects raw apiKey persistence. Agent profiles cover OpenHands/ACP variants with schema_version/id/revision/mcp refs, discriminator defaults, cross-variant rejection, ACP provider validation, null-vs-empty MCP refs, and no raw secrets. Settings now include ConversationSettings (max_iterations + observability metadata/tags, no confirmation/security fields per project deviation) and AgentSettings variants that use llm_profile_ref instead of embedded LLM/api_key. Verification: npm test, typecheck, lint, and build pass (68 tests). Commits: fd63387, 98e8882, e2c5794, ab28680.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.45944+02:00","updated_at":"2026-06-24T04:21:16.096466+02:00","closed_at":"2026-06-24T04:21:16.096466+02:00","dependencies":[{"issue_id":"openhands-agent-2rb","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.902124+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-2rb","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.374182+02:00","created_by":"daemon"}]} -{"id":"openhands-agent-5sg","title":"P6 — Context, condenser, skills","description":"Transpile context-window management, condensation, agent context, and skill discovery/validation. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.675828+02:00","updated_at":"2026-06-24T06:16:10.286343+02:00","closed_at":"2026-06-24T06:16:10.286343+02:00","dependencies":[{"issue_id":"openhands-agent-5sg","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.158281+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-5sg","depends_on_id":"openhands-agent-w38","type":"blocks","created_at":"2026-06-24T01:10:17.71862+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-2rb","title":"P2 \u2014 Types & settings models","description":"Transpile settings models and profiles, including SecretRef and a keyring-backed SecretStore abstraction. Do NOT port Python's Cipher, local plaintext secret persistence, or docker/remote/agent-server encrypted-at-rest branching. Settings/profiles persist secret references only. Raw secret values live in OS keyring service 'openhands'. LLM API keys are provider-scoped by default using accounts like llm-provider:, with explicit per-profile override accounts like llm-profile::api-key for cases where the same provider needs a second credential (for example app/eval litellm_proxy profiles). Pure-data; validates the zod approach at scale. Serialization round-trip tests. Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: P2 in progress. Implemented keyring-backed secret references, raw-secret-free LLM profiles, secret-free AgentProfile schemas, and profile-first settings schemas. Secret work: SecretRef serializes only {service, account}; InMemorySecretStore for tests; MacOSKeychainSecretStore via macOS security CLI; provider/profile LLM refs and resolution semantics. LLM profile schema covers providerId/model/baseUrl/generation params/headers/useProfileKeyOverride and rejects raw apiKey persistence. Agent profiles cover OpenHands/ACP variants with schema_version/id/revision/mcp refs, discriminator defaults, cross-variant rejection, ACP provider validation, null-vs-empty MCP refs, and no raw secrets. Settings now include ConversationSettings (max_iterations + observability metadata/tags, no confirmation/security fields per project deviation) and AgentSettings variants that use llm_profile_ref instead of embedded LLM/api_key. Verification: npm test, typecheck, lint, and build pass (68 tests). Commits: fd63387, 98e8882, e2c5794, ab28680.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.45944+02:00","updated_at":"2026-06-24T04:21:16.096466+02:00","closed_at":"2026-06-24T04:21:16.096466+02:00","dependencies":[{"issue_id":"openhands-agent-2rb","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.902124+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-2rb","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.374182+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-5sg","title":"P6 \u2014 Context, condenser, skills","description":"Transpile context-window management, condensation, agent context, and skill discovery/validation. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.675828+02:00","updated_at":"2026-06-24T06:16:10.286343+02:00","closed_at":"2026-06-24T06:16:10.286343+02:00","dependencies":[{"issue_id":"openhands-agent-5sg","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.158281+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-5sg","depends_on_id":"openhands-agent-w38","type":"blocks","created_at":"2026-06-24T01:10:17.71862+02:00","created_by":"daemon"}]} {"id":"openhands-agent-5up","title":"Fix OpenAI usage parsing and real LLM examples","description":"OpenAI chat completions now return usage detail fields that break strict usage parsing. Make provider usage parsing tolerant where appropriate, add regression coverage, and add a shared env-backed example profile helper so examples CI exercises real LLM profiles via OPENAI_API_KEY while local no-key runs skip gracefully.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-07-05T08:30:36.910185+02:00","updated_at":"2026-07-05T08:35:35.049693+02:00","closed_at":"2026-07-05T08:35:35.049693+02:00","labels":["ci","examples","llm"]} -{"id":"openhands-agent-6ay","title":"P9 — Packaging, examples, docs, release 0.1.0","description":"Finalize packaging (exports, files), write examples and docs, cut release 0.1.0. Parent: openhands-agent-jad.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-06-24T01:10:08.83522+02:00","updated_at":"2026-06-24T06:40:37.756711+02:00","closed_at":"2026-06-24T06:40:37.756711+02:00","dependencies":[{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.319541+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-5sg","type":"blocks","created_at":"2026-06-24T01:10:17.886769+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-er1","type":"blocks","created_at":"2026-06-24T01:10:17.940442+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-dno","type":"blocks","created_at":"2026-06-24T01:10:17.993948+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-6ay","title":"P9 \u2014 Packaging, examples, docs, release 0.1.0","description":"Finalize packaging (exports, files), write examples and docs, cut release 0.1.0. Parent: openhands-agent-jad.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-06-24T01:10:08.83522+02:00","updated_at":"2026-06-24T06:40:37.756711+02:00","closed_at":"2026-06-24T06:40:37.756711+02:00","dependencies":[{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.319541+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-5sg","type":"blocks","created_at":"2026-06-24T01:10:17.886769+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-er1","type":"blocks","created_at":"2026-06-24T01:10:17.940442+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-dno","type":"blocks","created_at":"2026-06-24T01:10:17.993948+02:00","created_by":"daemon"}]} {"id":"openhands-agent-7c5","title":"Add provider-specific examples for Gemini and Anthropic","description":"examples/_shared/exampleProfile.ts currently routes through createLlmClientFromProfile, so examples exercise OpenAI-compatible chat only even though CI now provides GEMINI_API_KEY and ANTHROPIC_API_KEY. Add provider-specific examples or route examples by provider if Engel wants Gemini/Anthropic coverage beyond live:* smoke scripts.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-07-07T06:09:33.706758+02:00","updated_at":"2026-07-07T06:20:42.425504+02:00","closed_at":"2026-07-07T06:20:42.425504+02:00","labels":["examples","llm","providers"]} {"id":"openhands-agent-7l9","title":"Seal smolpaws rename and settings seam","description":"Confirm Workspace/AgentServerWorkspace/OpenHandsSettings rename surface against smolpaws and old SDK. Add any package-level compatibility exports/tests needed for LocalWorkspace/RemoteWorkspace instanceof guard and OpenHandsAgentSettings field parity.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T01:52:37.095045+02:00","updated_at":"2026-07-05T01:54:17.425134+02:00","closed_at":"2026-07-05T01:54:17.425134+02:00","labels":["interop","settings","smolpaws"]} -{"id":"openhands-agent-a13","title":"P3 — Tool abstraction + registry","description":"Transpile the tool module: base Tool, tool registry, JSON-schema generation via zod v4 z.toJSONSchema() (replaces pydantic model_json_schema()). Then one concrete tool end-to-end as a vertical slice. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P3 tool abstraction and registry implemented and verified. Added zod-backed ToolDefinition with input/output validation, executor dispatch, MCP tool export via z.toJSONSchema(), Responses function-tool export, ToolAnnotations schema, ToolSpec schema, registry instance/factory resolution, usable filtering, and clear unknown/no-executor errors. Added concrete built-in vertical slice with FinishTool and ThinkTool, safe annotations, zod action schemas, observation validation, root exports, and tests. Verification: npm test, typecheck, lint, and build pass (77 tests). Commits: c283751, a0916a2.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.513669+02:00","updated_at":"2026-06-24T04:59:04.414849+02:00","closed_at":"2026-06-24T04:59:04.414849+02:00","dependencies":[{"issue_id":"openhands-agent-a13","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.992812+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-a13","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.427688+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-a13","title":"P3 \u2014 Tool abstraction + registry","description":"Transpile the tool module: base Tool, tool registry, JSON-schema generation via zod v4 z.toJSONSchema() (replaces pydantic model_json_schema()). Then one concrete tool end-to-end as a vertical slice. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P3 tool abstraction and registry implemented and verified. Added zod-backed ToolDefinition with input/output validation, executor dispatch, MCP tool export via z.toJSONSchema(), Responses function-tool export, ToolAnnotations schema, ToolSpec schema, registry instance/factory resolution, usable filtering, and clear unknown/no-executor errors. Added concrete built-in vertical slice with FinishTool and ThinkTool, safe annotations, zod action schemas, observation validation, root exports, and tests. Verification: npm test, typecheck, lint, and build pass (77 tests). Commits: c283751, a0916a2.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.513669+02:00","updated_at":"2026-06-24T04:59:04.414849+02:00","closed_at":"2026-06-24T04:59:04.414849+02:00","dependencies":[{"issue_id":"openhands-agent-a13","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.992812+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-a13","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.427688+02:00","created_by":"daemon"}]} {"id":"openhands-agent-bbh","title":"Add non-blocking async FileStore lock API","description":"Follow-up from PR #2 inline review: FileStore.lock is synchronous for the current local EventLog parity slice and its contention wait blocks the Node.js event loop. Design and implement an async lock API, then migrate server/runtime paths that may experience lock contention to non-blocking retries/timers while preserving the synchronous local API where needed for compatibility.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-07-11T05:32:20.629245+02:00","updated_at":"2026-07-12T02:46:09.190932+02:00","labels":["async","event-log","follow-up","io"],"closed_at":"2026-07-12T02:46:09.190932+02:00","notes":"Completed: added FileStore.lockAsync() for LocalFileStore and InMemoryFileStore, EventLog.appendAsync()/appendMultipleAsync(), ConversationState.appendEventAsync(), LocalConversation.sendMessageAsync(), and migrated async response dispatch/run error persistence to async appends. Validation: typecheck, lint, tests, build, example typecheck, and example tests pass."} {"id":"openhands-agent-dcw","title":"Document architecture and release 0.2.0","description":"Update docs/ for current TypeScript SDK status, add architecture documentation for main components, bump package release to 0.2.0, add release notes, verify, commit, tag, and push.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T03:04:06.371637+02:00","updated_at":"2026-07-05T03:07:26.939045+02:00","closed_at":"2026-07-05T03:07:26.939045+02:00","labels":["architecture","docs","release"]} -{"id":"openhands-agent-dno","title":"P8 — Concrete tools (openhands-tools equivalent)","description":"Transpile the concrete tools: terminal, file editor, browser, grep/glob, task tracker, etc. May become a separate package @smolpaws/openhands-tools later. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.782723+02:00","updated_at":"2026-06-24T06:36:00.662039+02:00","closed_at":"2026-06-24T06:36:00.662039+02:00","dependencies":[{"issue_id":"openhands-agent-dno","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.263246+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-dno","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.831398+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-dno","title":"P8 \u2014 Concrete tools (openhands-tools equivalent)","description":"Transpile the concrete tools: terminal, file editor, browser, grep/glob, task tracker, etc. May become a separate package @smolpaws/openhands-tools later. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.782723+02:00","updated_at":"2026-06-24T06:36:00.662039+02:00","closed_at":"2026-06-24T06:36:00.662039+02:00","dependencies":[{"issue_id":"openhands-agent-dno","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.263246+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-dno","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.831398+02:00","created_by":"daemon"}]} {"id":"openhands-agent-eae","title":"Port LLM provider format quirks","description":"Port scoped provider API format quirks from the legacy TS SDK into the fresh LLM clients: GPT-5 temperature stripping, Anthropic extended thinking/temp/budget/signature/cache-control, Gemini thinkingConfig and thoughtSignature round-trip, and regression tests.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-06T04:02:29.262578+02:00","updated_at":"2026-07-06T04:08:53.081082+02:00","closed_at":"2026-07-06T04:08:53.081082+02:00","labels":["llm","parity","providers"]} -{"id":"openhands-agent-er1","title":"P7 — Surrounding subsystems: hooks, critic, subagent, git, mcp (no security/confirmation)","description":"Transpile the surrounding subsystems: hooks, critic, subagent/delegation, git integration, and MCP client. Do NOT port security analyzers, risk scoring, confirmation gates, Python Cipher, or Python's secret storage split. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.729954+02:00","updated_at":"2026-06-24T06:29:20.730137+02:00","closed_at":"2026-06-24T06:29:20.730137+02:00","dependencies":[{"issue_id":"openhands-agent-er1","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.209996+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-er1","depends_on_id":"openhands-agent-w38","type":"blocks","created_at":"2026-06-24T01:10:17.773948+02:00","created_by":"daemon"}]} -{"id":"openhands-agent-jad","title":"Plan: transpile Python OpenHands agent-sdk to idiomatic TypeScript","description":"# Transpile Plan — Python OpenHands agent-sdk → idiomatic TypeScript\n\n\u003e Source of truth for the roadmap is the beads issue **`openhands-agent-jad`**.\n\u003e This doc mirrors it in Markdown for easy reading. Keep them in sync.\n\n## Objective\n\nProduce `@smolpaws/openhands-agent`: a fresh, idiomatic TypeScript implementation of the\nOpenHands Python `agent-sdk` (local source: `~/repos/agent-sdk`, upstream\n`OpenHands/software-agent-sdk`). We transpile *anew* — we do **not** copy the outdated TS\nattempt in `oh-tab/packages/agent-sdk`. That older code is reference-only (tooling, tests).\n\n## Pinned upstream target\n\nPython `OpenHands/software-agent-sdk` main @\n**`966340979be26c2162e9ab8805557b715e1f1a78`** (2026-06-23). We transpile against exactly this\ncommit and catch up to newer upstream in deliberate batches, not by chasing HEAD.\n\n## Source scope (Python core `openhands-sdk/openhands/sdk`, ~59k LOC, 93 pydantic files)\n\n| Module | Files | ~LOC | Notes |\n|--------|-------|------|-------|\n| llm | 40 | 10364 | LiteLLM-backed; biggest + riskiest |\n| conversation | 29 | 8378 | Local + Remote conversation, state, event loop |\n| agent | 9 | 7685 | The agent loop / step logic |\n| context | 25 | 3301 | Condenser, skills context, agent context |\n| settings | 5 | 3127 | Settings models |\n| skills | 9 | 2586 | Skill discovery/validation |\n| workspace | 10 | 2528 | Local/Remote/Apple workspace |\n| tool | 11 | 2360 | Tool base + registry |\n| security | 15 | 2084 | Confirmation, risk, analyzer |\n| utils | 16 | 2004 | Shared helpers |\n| event | 19 | 1923 | Event model hierarchy |\n| hooks | 6 | 1669 | Lifecycle hooks |\n| plugin | 7 | 1471 | Plugin system |\n| critic | 12 | 1446 | Critic models |\n| git | 6 | 1355 | Git integration |\n| profiles | 5 | 1267 | LLM profiles |\n| subagent | 4 | 1011 | Delegation |\n| extensions | 8 | 988 | |\n| mcp | 6 | 750 | MCP client |\n| marketplace | 4 | 649 | |\n| observability | 3 | 447 | |\n| io | 5 | 431 | |\n| logger | 3 | 330 | |\n| testing | 2 | 339 | test helpers |\n| secret | ? | 155 | Python source reference only; TS uses OS keyring, not Python's plaintext/encrypted-at-rest split |\n\nPlus `openhands-tools` (~16k LOC): concrete tools (terminal, file editor, browser, etc.).\n`openhands-agent-server` is out of scope for now (possible later sibling package).\n\n## Workflow: tests first (red/green)\n\n**The first thing in every unit of work is tests.** We port the Python tests *and* the examples\nbefore (or alongside) the implementation, and drive each module red → green:\n\n1. Port the relevant Python tests to vitest (conceptually — adapt to TS idioms, don't copy).\n2. Port the relevant examples so they compile and run against the new API.\n3. Watch them fail (red).\n4. Implement until they pass (green).\n\nExamples and tests are first-class deliverables, not an afterthought — they define the public\nAPI shape and are the executable spec for each phase.\n\n## Principles\n\n1. **Idiomatic TS, not literal port.** Respect the architecture (event/conversation/agent\n separation, tool abstraction) but use TS idioms: discriminated unions over class hierarchies\n where natural, `readonly`, narrow types, no Python-isms.\n2. **Type enforcement is non-negotiable.** `strict` + `noUncheckedIndexedAccess` +\n `exactOptionalPropertyTypes` + `verbatimModuleSyntax`. `no-explicit-any` is an error.\n3. **Runtime validation = zod v4.** The pydantic equivalent. Pydantic `BaseModel` → zod schema +\n `z.infer` type. zod v4's native `z.toJSONSchema()` covers the spots Python uses\n `model_json_schema()` (tool/settings schemas) — no separate `zod-to-json-schema` dep.\n4. **No code copy.** Read Python for behavior, write TS fresh. Port tests conceptually too.\n5. **Tooling parity with oh-tab** unless justified: tsup (ESM+CJS), vitest, eslint\n type-checked, tsc strict, target ES2022.\n6. **Wire-protocol compatibility.** TS types must serialize to the same JSON the Python SDK and\n agent-server expect. Round-trip serialization tests are the correctness anchor.\n7. **Secret safety overrides source parity.** Settings and profiles may persist secret references,\n never raw secret values. Runtime secret values live in an OS keyring backend (macOS Keychain\n first) under the `openhands` service; encryption/cipher/plaintext-storage machinery from\n Python is not ported. LLM API keys are provider-scoped by default, with explicit per-profile\n overrides only when the same provider needs multiple credentials.\n\n## Decisions (resolved 2026-06-23 with Engel)\n\n1. **zod v4** (4.4.3). Native JSON Schema; drop `zod-to-json-schema`. Done.\n2. **Single package** for starters; split into npm workspaces later.\n3. **LLM: thin abstraction, fat clients.** `LLMClient` is a deliberately thin interface; most\n logic lives inside each client. **Do not over-abstract.** Four clients, each owning its API's\n correctness + performance (request building, streaming, prompt caching, error mapping):\n - OpenAI / OpenAI-compatible (chat completions)\n - Anthropic Messages\n - Gemini (new interactions API)\n - OpenAI Responses API\n\n The shared surface is *extracted from what clients actually share*, built last — not designed\n up front. Live-test scripts live in `scripts/live/` (NOT CI), keys from a GitHub environment\n named `llm`, run on demand to confirm each API still works.\n4. **Pin upstream** at `9663409` (above). Local `~/repos/agent-sdk` synced to it.\n5. **Secrets: OS keyring, not Python's storage split.** The Python SDK has environment-specific\n secret behavior (plaintext local paths plus encrypted-at-rest docker/remote/agent-server\n handling). We intentionally do not port that complexity. The TS package persists only secret\n references in settings/profiles and stores actual values in the OS keyring under service\n `openhands`. macOS Keychain is the first supported backend; add Windows Credential Manager or\n Linux Secret Service later only if the abstraction stays simple. Environment variables may be\n used as ephemeral import/input, but not as persistent storage. LLM key resolution follows the\n OpenHands-Tab profile behavior: provider key as the shared default, optional per-profile\n override when a particular profile needs a different credential for the same provider.\n\n## Intended deviations from the Python SDK\n\nWe transpile **anew**, and on purpose we do NOT reproduce everything. The rule on public API:\n\n\u003e **Public APIs should be consistent with the Python SDK across the transpilation** —\n\u003e same shapes, same names (adapted to TS idioms) — **EXCEPT** for the deviations below.\n\u003e And even there, clean code / clean APIs win over fidelity. Idiomatic, clean TS is more\n\u003e important than matching Python signature-for-signature.\n\n1. **No security analyzers. None.** We do not port the risk/security analyzer machinery. Drop it\n entirely — no `SecurityAnalyzer`, no risk scoring, no analyzer hooks.\n2. **No confirmation mechanism. None.** No confirmation policy, no human-in-the-loop confirm\n gates, no approval step before an action runs. The agent acts; we don't gate it.\n **IMPORTANT — this is NOT the pending-actions queue.** We absolutely KEEP the multi-tool-use\n pending-action mechanics: when the LLM emits multiple tool calls in one response, those become\n a queue of `ActionEvent`s, executed (incl. in parallel via the `ParallelToolExecutor`\n equivalent), with the \"unmatched actions\" tracking (`get_unmatched_actions`) and cancellation\n support. That is core execution machinery and is required. Only the *confirmation gate* is\n dropped — not the action queue.\n3. **LLM is used ONLY via LLM profiles.** There is no bare/standalone `LLM` entry point in the\n public API. You configure and select a profile; the SDK resolves the client from the profile.\n **No model fallback chains, no implicit default model, nothing** — just profiles. (The 4 clients\n from decision 3 sit *behind* the profile resolution, never exposed bare.)\n4. **Secrets are keyring-backed references.** Do not port Python's `Cipher`, local plaintext\n secret persistence, or docker/remote/agent-server encrypted-at-rest branching. Persistent\n settings/profiles contain stable references such as `{ service: 'openhands', account }`; the raw\n value is written to and read from OS keyring at runtime, then redacted from logs/events.\n Provider keys use accounts like `llm-provider:\u003cproviderId\u003e` (for example `llm-provider:openai`\n or `llm-provider:litellm_proxy`). Per-profile overrides use accounts like\n `llm-profile:\u003cprofileId\u003e:api-key`, and are only used when enabled/selected for that profile.\n\n### LLM key resolution\n\nLLM API key lookup is provider-driven, not model-family-driven. A profile whose provider is\n`litellm_proxy` must resolve a `litellm_proxy` key even if its model string looks like an OpenAI,\nAnthropic, or Gemini model. The default keyring account for a provider is:\n\n- service: `openhands`\n- account: `llm-provider:\u003cproviderId\u003e`\n\nProfiles may opt into a profile-scoped key only when the same provider needs distinct credentials\nor endpoints. This covers cases like an app LiteLLM proxy profile and an eval LiteLLM proxy profile\nthat both use provider `litellm_proxy` but need different proxy API keys. The profile override\naccount is:\n\n- service: `openhands`\n- account: `llm-profile:\u003cprofileId\u003e:api-key`\n\nResolution for a profile:\n\n1. If the profile explicitly enables a profile key override and that key exists, use\n `llm-profile:\u003cprofileId\u003e:api-key`.\n2. Otherwise use `llm-provider:\u003cproviderId\u003e`.\n3. If neither exists, fail with a clear error telling the caller to set the provider key or enable\n and set a profile override.\n\nConsequence for the roadmap:\n- The Python `security` module (~2084 LOC: confirmation + risk + analyzer) is **mostly dropped**.\n P7 no longer includes security analyzers or confirmation. If any non-security piece currently\n lives under `security/` and is genuinely needed elsewhere, it moves to its real home — but the\n analyzer/confirmation surface itself is gone.\n- The LLM public surface is **profile-first**: `LLMProfile` in, resolved client out. Bare `LLM`\n is not part of the public API.\n- Secret handling is its own small settings/profile concern, not a port of Python's cipher stack:\n implement `SecretRef`/`SecretStore` around OS keyring, then make provider profiles refer to\n secrets by reference.\n\n## Phased roadmap\n\nEach phase is a bead (see `bd list`). Dependencies chained so `bd ready` surfaces the next\nworkable phase.\n\n- **P1 — Foundations:** utils, logger, io, event model. Low-dependency leaves first; establishes\n the zod patterns and the event discriminated-union shape everything builds on.\n- **P2 — Types \u0026 settings:** settings models, profiles, `SecretRef`, and the keyring-backed\n `SecretStore` abstraction. Settings/profiles serialize references only, never raw values.\n Model provider-key and profile-override references explicitly. Validates the zod approach at\n scale. Serialization round-trip tests. (deps: P1)\n- **P3 — Tool abstraction + registry:** base Tool, schema gen via `z.toJSONSchema()`, then one\n concrete tool end-to-end. (deps: P1)\n- **P4 — LLM layer (profile-first):** profiles are the *only* public entry point. The four\n clients (one sub-bead each, done end-to-end) sit behind profile resolution — never exposed\n bare. Profiles resolve API keys through `SecretRef`/keyring, not embedded values: explicit\n profile override first when enabled, otherwise provider key by `providerId` (not by model\n family). No model fallback chains, no implicit default model. Plus the live-test harness +\n `llm` environment, then the minimal shared interface extracted last. (deps: P1)\n- **P5 — Conversation + agent loop:** LocalConversation, RemoteConversation, ConversationState,\n agent step loop, stuck detection. (deps: P3, P4)\n- **P6 — Context \u0026 condenser, skills:** context-window management, condensation, skill\n discovery/validation. (deps: P5)\n- **P7 — Surrounding subsystems:** hooks, critic, subagent, git, mcp. **No security analyzers\n and no confirmation mechanism** (see Intended deviations). (deps: P5)\n- **P8 — Concrete tools** (`openhands-tools` equivalent): terminal, file editor, browser,\n grep/glob, task tracker, etc. May become a separate package later. (deps: P3)\n- **P9 — Packaging, examples, docs, release 0.1.0.** (deps: P6, P7, P8)\n\n## Reference materials\n\n- Python source: `~/repos/agent-sdk/openhands-sdk/openhands/sdk` and `openhands-tools`\n- Old TS attempt (reference only, do not copy): `~/repos/oh-tab/packages/agent-sdk`\n- Wire protocol: agent-server API + `OpenHands/typescript-client`\n","notes":"Current decisions: zod v4; single package for starters; profile-first LLM with thin shared interface and fat provider clients; upstream pinned to 966340979be26c2162e9ab8805557b715e1f1a78; tests/examples first. Intended deviations from Python: no security analyzers, no confirmation gate, no bare public LLM, and no Python secret storage stack. Secret handling is OS keyring based under service 'openhands': persist SecretRef-style references in settings/profiles, keep raw values in keyring, resolve at use time, and do not port Python Cipher/plaintext local persistence/docker-remote-agent-server encrypted-at-rest branching. LLM keys are provider-scoped by default (llm-provider:\u003cproviderId\u003e) with explicit per-profile overrides (llm-profile:\u003cprofileId\u003e:api-key) when the same provider needs distinct credentials, e.g. app/eval litellm_proxy profiles. P2 owns SecretRef/SecretStore; P4 consumes it for provider API keys.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T00:33:36.815512+02:00","updated_at":"2026-06-24T03:32:02.174431+02:00","closed_at":"2026-06-24T02:28:11.213775+02:00"} +{"id":"openhands-agent-er1","title":"P7 \u2014 Surrounding subsystems: hooks, critic, subagent, git, mcp (no security/confirmation)","description":"Transpile the surrounding subsystems: hooks, critic, subagent/delegation, git integration, and MCP client. Do NOT port security analyzers, risk scoring, confirmation gates, Python Cipher, or Python's secret storage split. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.729954+02:00","updated_at":"2026-06-24T06:29:20.730137+02:00","closed_at":"2026-06-24T06:29:20.730137+02:00","dependencies":[{"issue_id":"openhands-agent-er1","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.209996+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-er1","depends_on_id":"openhands-agent-w38","type":"blocks","created_at":"2026-06-24T01:10:17.773948+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-jad","title":"Plan: transpile Python OpenHands agent-sdk to idiomatic TypeScript","description":"# Transpile Plan \u2014 Python OpenHands agent-sdk \u2192 idiomatic TypeScript\n\n> Source of truth for the roadmap is the beads issue **`openhands-agent-jad`**.\n> This doc mirrors it in Markdown for easy reading. Keep them in sync.\n\n## Objective\n\nProduce `@smolpaws/openhands-agent`: a fresh, idiomatic TypeScript implementation of the\nOpenHands Python `agent-sdk` (local source: `~/repos/agent-sdk`, upstream\n`OpenHands/software-agent-sdk`). We transpile *anew* \u2014 we do **not** copy the outdated TS\nattempt in `oh-tab/packages/agent-sdk`. That older code is reference-only (tooling, tests).\n\n## Pinned upstream target\n\nPython `OpenHands/software-agent-sdk` main @\n**`966340979be26c2162e9ab8805557b715e1f1a78`** (2026-06-23). We transpile against exactly this\ncommit and catch up to newer upstream in deliberate batches, not by chasing HEAD.\n\n## Source scope (Python core `openhands-sdk/openhands/sdk`, ~59k LOC, 93 pydantic files)\n\n| Module | Files | ~LOC | Notes |\n|--------|-------|------|-------|\n| llm | 40 | 10364 | LiteLLM-backed; biggest + riskiest |\n| conversation | 29 | 8378 | Local + Remote conversation, state, event loop |\n| agent | 9 | 7685 | The agent loop / step logic |\n| context | 25 | 3301 | Condenser, skills context, agent context |\n| settings | 5 | 3127 | Settings models |\n| skills | 9 | 2586 | Skill discovery/validation |\n| workspace | 10 | 2528 | Local/Remote/Apple workspace |\n| tool | 11 | 2360 | Tool base + registry |\n| security | 15 | 2084 | Confirmation, risk, analyzer |\n| utils | 16 | 2004 | Shared helpers |\n| event | 19 | 1923 | Event model hierarchy |\n| hooks | 6 | 1669 | Lifecycle hooks |\n| plugin | 7 | 1471 | Plugin system |\n| critic | 12 | 1446 | Critic models |\n| git | 6 | 1355 | Git integration |\n| profiles | 5 | 1267 | LLM profiles |\n| subagent | 4 | 1011 | Delegation |\n| extensions | 8 | 988 | |\n| mcp | 6 | 750 | MCP client |\n| marketplace | 4 | 649 | |\n| observability | 3 | 447 | |\n| io | 5 | 431 | |\n| logger | 3 | 330 | |\n| testing | 2 | 339 | test helpers |\n| secret | ? | 155 | Python source reference only; TS uses OS keyring, not Python's plaintext/encrypted-at-rest split |\n\nPlus `openhands-tools` (~16k LOC): concrete tools (terminal, file editor, browser, etc.).\n`openhands-agent-server` is out of scope for now (possible later sibling package).\n\n## Workflow: tests first (red/green)\n\n**The first thing in every unit of work is tests.** We port the Python tests *and* the examples\nbefore (or alongside) the implementation, and drive each module red \u2192 green:\n\n1. Port the relevant Python tests to vitest (conceptually \u2014 adapt to TS idioms, don't copy).\n2. Port the relevant examples so they compile and run against the new API.\n3. Watch them fail (red).\n4. Implement until they pass (green).\n\nExamples and tests are first-class deliverables, not an afterthought \u2014 they define the public\nAPI shape and are the executable spec for each phase.\n\n## Principles\n\n1. **Idiomatic TS, not literal port.** Respect the architecture (event/conversation/agent\n separation, tool abstraction) but use TS idioms: discriminated unions over class hierarchies\n where natural, `readonly`, narrow types, no Python-isms.\n2. **Type enforcement is non-negotiable.** `strict` + `noUncheckedIndexedAccess` +\n `exactOptionalPropertyTypes` + `verbatimModuleSyntax`. `no-explicit-any` is an error.\n3. **Runtime validation = zod v4.** The pydantic equivalent. Pydantic `BaseModel` \u2192 zod schema +\n `z.infer` type. zod v4's native `z.toJSONSchema()` covers the spots Python uses\n `model_json_schema()` (tool/settings schemas) \u2014 no separate `zod-to-json-schema` dep.\n4. **No code copy.** Read Python for behavior, write TS fresh. Port tests conceptually too.\n5. **Tooling parity with oh-tab** unless justified: tsup (ESM+CJS), vitest, eslint\n type-checked, tsc strict, target ES2022.\n6. **Wire-protocol compatibility.** TS types must serialize to the same JSON the Python SDK and\n agent-server expect. Round-trip serialization tests are the correctness anchor.\n7. **Secret safety overrides source parity.** Settings and profiles may persist secret references,\n never raw secret values. Runtime secret values live in an OS keyring backend (macOS Keychain\n first) under the `openhands` service; encryption/cipher/plaintext-storage machinery from\n Python is not ported. LLM API keys are provider-scoped by default, with explicit per-profile\n overrides only when the same provider needs multiple credentials.\n\n## Decisions (resolved 2026-06-23 with Engel)\n\n1. **zod v4** (4.4.3). Native JSON Schema; drop `zod-to-json-schema`. Done.\n2. **Single package** for starters; split into npm workspaces later.\n3. **LLM: thin abstraction, fat clients.** `LLMClient` is a deliberately thin interface; most\n logic lives inside each client. **Do not over-abstract.** Four clients, each owning its API's\n correctness + performance (request building, streaming, prompt caching, error mapping):\n - OpenAI / OpenAI-compatible (chat completions)\n - Anthropic Messages\n - Gemini (new interactions API)\n - OpenAI Responses API\n\n The shared surface is *extracted from what clients actually share*, built last \u2014 not designed\n up front. Live-test scripts live in `scripts/live/` (NOT CI), keys from a GitHub environment\n named `llm`, run on demand to confirm each API still works.\n4. **Pin upstream** at `9663409` (above). Local `~/repos/agent-sdk` synced to it.\n5. **Secrets: OS keyring, not Python's storage split.** The Python SDK has environment-specific\n secret behavior (plaintext local paths plus encrypted-at-rest docker/remote/agent-server\n handling). We intentionally do not port that complexity. The TS package persists only secret\n references in settings/profiles and stores actual values in the OS keyring under service\n `openhands`. macOS Keychain is the first supported backend; add Windows Credential Manager or\n Linux Secret Service later only if the abstraction stays simple. Environment variables may be\n used as ephemeral import/input, but not as persistent storage. LLM key resolution follows the\n OpenHands-Tab profile behavior: provider key as the shared default, optional per-profile\n override when a particular profile needs a different credential for the same provider.\n\n## Intended deviations from the Python SDK\n\nWe transpile **anew**, and on purpose we do NOT reproduce everything. The rule on public API:\n\n> **Public APIs should be consistent with the Python SDK across the transpilation** \u2014\n> same shapes, same names (adapted to TS idioms) \u2014 **EXCEPT** for the deviations below.\n> And even there, clean code / clean APIs win over fidelity. Idiomatic, clean TS is more\n> important than matching Python signature-for-signature.\n\n1. **No security analyzers. None.** We do not port the risk/security analyzer machinery. Drop it\n entirely \u2014 no `SecurityAnalyzer`, no risk scoring, no analyzer hooks.\n2. **No confirmation mechanism. None.** No confirmation policy, no human-in-the-loop confirm\n gates, no approval step before an action runs. The agent acts; we don't gate it.\n **IMPORTANT \u2014 this is NOT the pending-actions queue.** We absolutely KEEP the multi-tool-use\n pending-action mechanics: when the LLM emits multiple tool calls in one response, those become\n a queue of `ActionEvent`s, executed (incl. in parallel via the `ParallelToolExecutor`\n equivalent), with the \"unmatched actions\" tracking (`get_unmatched_actions`) and cancellation\n support. That is core execution machinery and is required. Only the *confirmation gate* is\n dropped \u2014 not the action queue.\n3. **LLM is used ONLY via LLM profiles.** There is no bare/standalone `LLM` entry point in the\n public API. You configure and select a profile; the SDK resolves the client from the profile.\n **No model fallback chains, no implicit default model, nothing** \u2014 just profiles. (The 4 clients\n from decision 3 sit *behind* the profile resolution, never exposed bare.)\n4. **Secrets are keyring-backed references.** Do not port Python's `Cipher`, local plaintext\n secret persistence, or docker/remote/agent-server encrypted-at-rest branching. Persistent\n settings/profiles contain stable references such as `{ service: 'openhands', account }`; the raw\n value is written to and read from OS keyring at runtime, then redacted from logs/events.\n Provider keys use accounts like `llm-provider:` (for example `llm-provider:openai`\n or `llm-provider:litellm_proxy`). Per-profile overrides use accounts like\n `llm-profile::api-key`, and are only used when enabled/selected for that profile.\n\n### LLM key resolution\n\nLLM API key lookup is provider-driven, not model-family-driven. A profile whose provider is\n`litellm_proxy` must resolve a `litellm_proxy` key even if its model string looks like an OpenAI,\nAnthropic, or Gemini model. The default keyring account for a provider is:\n\n- service: `openhands`\n- account: `llm-provider:`\n\nProfiles may opt into a profile-scoped key only when the same provider needs distinct credentials\nor endpoints. This covers cases like an app LiteLLM proxy profile and an eval LiteLLM proxy profile\nthat both use provider `litellm_proxy` but need different proxy API keys. The profile override\naccount is:\n\n- service: `openhands`\n- account: `llm-profile::api-key`\n\nResolution for a profile:\n\n1. If the profile explicitly enables a profile key override and that key exists, use\n `llm-profile::api-key`.\n2. Otherwise use `llm-provider:`.\n3. If neither exists, fail with a clear error telling the caller to set the provider key or enable\n and set a profile override.\n\nConsequence for the roadmap:\n- The Python `security` module (~2084 LOC: confirmation + risk + analyzer) is **mostly dropped**.\n P7 no longer includes security analyzers or confirmation. If any non-security piece currently\n lives under `security/` and is genuinely needed elsewhere, it moves to its real home \u2014 but the\n analyzer/confirmation surface itself is gone.\n- The LLM public surface is **profile-first**: `LLMProfile` in, resolved client out. Bare `LLM`\n is not part of the public API.\n- Secret handling is its own small settings/profile concern, not a port of Python's cipher stack:\n implement `SecretRef`/`SecretStore` around OS keyring, then make provider profiles refer to\n secrets by reference.\n\n## Phased roadmap\n\nEach phase is a bead (see `bd list`). Dependencies chained so `bd ready` surfaces the next\nworkable phase.\n\n- **P1 \u2014 Foundations:** utils, logger, io, event model. Low-dependency leaves first; establishes\n the zod patterns and the event discriminated-union shape everything builds on.\n- **P2 \u2014 Types & settings:** settings models, profiles, `SecretRef`, and the keyring-backed\n `SecretStore` abstraction. Settings/profiles serialize references only, never raw values.\n Model provider-key and profile-override references explicitly. Validates the zod approach at\n scale. Serialization round-trip tests. (deps: P1)\n- **P3 \u2014 Tool abstraction + registry:** base Tool, schema gen via `z.toJSONSchema()`, then one\n concrete tool end-to-end. (deps: P1)\n- **P4 \u2014 LLM layer (profile-first):** profiles are the *only* public entry point. The four\n clients (one sub-bead each, done end-to-end) sit behind profile resolution \u2014 never exposed\n bare. Profiles resolve API keys through `SecretRef`/keyring, not embedded values: explicit\n profile override first when enabled, otherwise provider key by `providerId` (not by model\n family). No model fallback chains, no implicit default model. Plus the live-test harness +\n `llm` environment, then the minimal shared interface extracted last. (deps: P1)\n- **P5 \u2014 Conversation + agent loop:** LocalConversation, RemoteConversation, ConversationState,\n agent step loop, stuck detection. (deps: P3, P4)\n- **P6 \u2014 Context & condenser, skills:** context-window management, condensation, skill\n discovery/validation. (deps: P5)\n- **P7 \u2014 Surrounding subsystems:** hooks, critic, subagent, git, mcp. **No security analyzers\n and no confirmation mechanism** (see Intended deviations). (deps: P5)\n- **P8 \u2014 Concrete tools** (`openhands-tools` equivalent): terminal, file editor, browser,\n grep/glob, task tracker, etc. May become a separate package later. (deps: P3)\n- **P9 \u2014 Packaging, examples, docs, release 0.1.0.** (deps: P6, P7, P8)\n\n## Reference materials\n\n- Python source: `~/repos/agent-sdk/openhands-sdk/openhands/sdk` and `openhands-tools`\n- Old TS attempt (reference only, do not copy): `~/repos/oh-tab/packages/agent-sdk`\n- Wire protocol: agent-server API + `OpenHands/typescript-client`\n","notes":"Current decisions: zod v4; single package for starters; profile-first LLM with thin shared interface and fat provider clients; upstream pinned to 966340979be26c2162e9ab8805557b715e1f1a78; tests/examples first. Intended deviations from Python: no security analyzers, no confirmation gate, no bare public LLM, and no Python secret storage stack. Secret handling is OS keyring based under service 'openhands': persist SecretRef-style references in settings/profiles, keep raw values in keyring, resolve at use time, and do not port Python Cipher/plaintext local persistence/docker-remote-agent-server encrypted-at-rest branching. LLM keys are provider-scoped by default (llm-provider:) with explicit per-profile overrides (llm-profile::api-key) when the same provider needs distinct credentials, e.g. app/eval litellm_proxy profiles. P2 owns SecretRef/SecretStore; P4 consumes it for provider API keys.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T00:33:36.815512+02:00","updated_at":"2026-06-24T03:32:02.174431+02:00","closed_at":"2026-06-24T02:28:11.213775+02:00"} {"id":"openhands-agent-kwc","title":"Seal smolpaws pure helper import seam","description":"Port and export isMessageEvent, isConversationStateUpdateEvent, and reduceTextContent for smolpaws SDK swap read-path parity. Ground in swap-surface page, current TS package, and Python/old SDK event/message shapes; prove with tests.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T01:48:56.943931+02:00","updated_at":"2026-07-05T01:52:14.51193+02:00","closed_at":"2026-07-05T01:52:14.51193+02:00","labels":["helpers","interop","smolpaws"]} {"id":"openhands-agent-kx8","title":"Close post-0.1.0 transpilation gaps","description":"Parent for remaining TRANSPILE_PLAN gaps after 0.1.0. Scope excludes marketplace and plugins unless explicitly re-added. Work tests-first where applicable and preserve plan exceptions.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-06-26T05:44:25.817803+02:00","updated_at":"2026-06-26T06:03:11.839594+02:00","closed_at":"2026-06-26T06:03:11.839594+02:00","labels":["epic","follow-up","transpile"]} {"id":"openhands-agent-kx8.1","title":"Remove confirmation residues from TS SDK","description":"Remove public and local confirmation-shaped APIs and metadata handling while preserving pending-action and multi-tool execution. Review RemoteConversation.rejectPendingActions and subagent permission_mode confirm values. No dedicated cleanup tests required; run existing checks.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:44:32.317572+02:00","updated_at":"2026-06-26T05:48:21.751211+02:00","closed_at":"2026-06-26T05:48:21.751211+02:00","labels":["cleanup","confirmation","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.1","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:44:32.318689+02:00","created_by":"daemon"}]} @@ -27,11 +27,11 @@ {"id":"openhands-agent-kx8.6","title":"Add no-op-safe TS observability wrapper","description":"Read Python observability/laminar.py and utils.py. Add idiomatic TS wrapper compatible with standard JS OpenTelemetry and, if practical, Laminar. It must be no-op when env vars are absent. Add tests first for env gating, no-op behavior, and action-name helpers.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:45:02.445219+02:00","updated_at":"2026-06-26T06:02:30.884898+02:00","closed_at":"2026-06-26T06:02:30.884898+02:00","labels":["observability","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.6","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:45:02.445781+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.6","depends_on_id":"openhands-agent-kx8.2","type":"blocks","created_at":"2026-06-26T05:45:02.446871+02:00","created_by":"daemon"}]} {"id":"openhands-agent-kx8.7","title":"Expand applicable Python tests and examples coverage","description":"Port applicable Python examples/tests after underlying gaps land: persistence, async send-message-while-running, condenser, remote conversation, workspace, extensions, observability, testing helpers, and wire restore. Examples workflow remains manual or test-examples label only.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:45:12.196265+02:00","updated_at":"2026-06-26T06:02:59.14236+02:00","closed_at":"2026-06-26T06:02:59.14236+02:00","labels":["examples","tests","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:45:12.196889+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.3","type":"blocks","created_at":"2026-06-26T05:45:12.198157+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.4","type":"blocks","created_at":"2026-06-26T05:45:12.198771+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.5","type":"blocks","created_at":"2026-06-26T05:45:12.199328+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.6","type":"blocks","created_at":"2026-06-26T05:45:12.199858+02:00","created_by":"daemon"}]} {"id":"openhands-agent-mvm","title":"Fix examples GitHub environment OPENAI_API_KEY","description":"Manual examples workflow on main at e301a19 reached the real OpenAI profile path, but GitHub Actions failed with OpenAI HTTP 401 invalid_api_key. Code/local live run succeeded with the injected local credential, so the GitHub examples environment secret likely needs updating.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-07-05T08:36:48.625798+02:00","updated_at":"2026-07-06T05:32:50.995031+02:00","closed_at":"2026-07-06T05:32:50.995031+02:00","labels":["ci","examples","secrets"]} -{"id": "openhands-agent-tools-anthropic", "title": "Implement Anthropic native tool calling", "description": "Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.", "notes": "Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions.", "status": "closed", "priority": 1, "issue_type": "task", "created_at": "2026-07-15T01:12:47.261769+02:00", "updated_at": "2026-07-31T20:49:33.806344+02:00", "labels": ["anthropic", "llm", "tools", "transpile"], "dependencies": [{"issue_id": "openhands-agent-tools-anthropic", "depends_on_id": "openhands-agent-tools-native", "type": "parent-child", "created_at": "2026-07-15T01:12:47.261769+02:00", "created_by": "openhands"}, {"issue_id": "openhands-agent-tools-anthropic", "depends_on_id": "openhands-agent-tools-research", "type": "blocks", "created_at": "2026-07-15T01:12:47.261769+02:00", "created_by": "openhands"}], "closed_at": "2026-07-31T20:49:33.806344+02:00"} -{"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Acceptance: unit tests cover Interactions request tool declarations, no-tools omission, function-call parsing, function-response/tool-result continuation, coexistence with thought signatures, and no regression in existing Gemini tests. Use current official Google Interactions API docs as the authority.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-anthropic","title":"Implement Anthropic native tool calling","description":"Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.","notes":"Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T20:49:33.806344+02:00","labels":["anthropic","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T20:49:33.806344+02:00"} +{"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Completed 2026-07-30: Migrated GeminiClient from legacy generateContent to current /v1beta/interactions in stateless store:false mode. Added flat ToolDefinition serialization, signed thought/model/function step replay, function_result continuation, parallel function_call parsing, Interactions usage parsing, schema compatibility stripping, focused tests, and a credential-gated native tool example. Live gemini-3.5-flash-lite run dispatched lookup_value then finish successfully. Full suite: 254 tests pass; typecheck, lint, build, and example typecheck pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:14:13.467478+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:14:13.467478+02:00"} {"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"OpenAI native tool passing is already done by PR #7; this epic tracks the remaining provider clients only.","status":"open","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","tools","transpile"]} {"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Acceptance: tests cover OpenAI-compatible chat payload tools, no-tools omission, custom baseUrl/proxy behavior, OpenRouter behavior if supported, and clear docs for any disabled or unverified provider dialect.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} -{"id": "openhands-agent-tools-research", "title": "Research provider-native tool APIs and bounded oh-tab reference", "description": "Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.", "notes": "Completed 2026-07-30: Research document at docs/NATIVE_TOOLS_RESEARCH.md covers Anthropic (tools array, tool_use blocks, tool_result), Gemini Interactions (type:function, function_call steps, function_result, stateful mode), OpenAI-compatible (reuse Chat shape, provider gating). Captured oh-tab patterns. 4-phase plan: Anthropic \u2192 Gemini + migration \u2192 OpenAI-compatible \u2192 validation.", "status": "closed", "priority": 1, "issue_type": "task", "created_at": "2026-07-15T01:12:47.261769+02:00", "updated_at": "2026-07-31T17:01:34.378353+02:00", "labels": ["anthropic", "gemini", "llm", "openai-compatible", "research", "tools"], "dependencies": [{"issue_id": "openhands-agent-tools-research", "depends_on_id": "openhands-agent-tools-native", "type": "parent-child", "created_at": "2026-07-15T01:12:47.261769+02:00", "created_by": "openhands"}], "closed_at": "2026-07-31T17:01:34.378353+02:00"} +{"id":"openhands-agent-tools-research","title":"Research provider-native tool APIs and bounded oh-tab reference","description":"Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.","notes":"Completed 2026-07-30: Research document at docs/NATIVE_TOOLS_RESEARCH.md covers Anthropic (tools array, tool_use blocks, tool_result), Gemini Interactions (type:function, function_call steps, function_result, stateful mode), OpenAI-compatible (reuse Chat shape, provider gating). Captured oh-tab patterns. 4-phase plan: Anthropic \u2192 Gemini + migration \u2192 OpenAI-compatible \u2192 validation.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T17:01:34.378353+02:00","labels":["anthropic","gemini","llm","openai-compatible","research","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-research","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T17:01:34.378353+02:00"} {"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Acceptance: npm test/typecheck/lint/build pass, provider-specific unit tests prove real serialization/parsing code paths, docs mention OpenAI done in PR #7, and examples/live smokes are opt-in with existing secret conventions.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} -{"id":"openhands-agent-w38","title":"P5 — Conversation + agent loop","description":"Conversation + agent loop. Transpile LocalConversation, RemoteConversation, ConversationState, the agent step loop, stuck detection. MUST include the multi-tool-use PENDING-ACTIONS QUEUE: when the LLM emits multiple tool calls, queue them as ActionEvents, execute (incl. parallel via ParallelToolExecutor equivalent), track unmatched actions (get_unmatched_actions), support cancellation/rejection of pending actions. This is core execution machinery (NOT confirmation) and is required. NO confirmation gate. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: Added RemoteConversation REST client slice. RemoteConversation now supports sendMessage without implicit run, run with optional blocking status polling, rejectPendingActions, pause, and interrupt over /api/conversations endpoints, with local executionStatus mirroring server terminal states. Verification after this slice: npm test, typecheck, lint, build pass (112 tests). P5 now covers ConversationState, LocalConversation, RemoteConversation, Agent step loop, stuck detection, pending/unmatched action queue, cancellation/rejection, and multi-tool parallel execution. Commits include 091c900, 38f112f, 56d9fa0, 9666ba6.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.619859+02:00","updated_at":"2026-06-24T05:57:53.318925+02:00","closed_at":"2026-06-24T05:57:53.318925+02:00","dependencies":[{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.105414+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.58951+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-2ba","type":"blocks","created_at":"2026-06-24T01:10:17.664653+02:00","created_by":"daemon"}]} -{"id":"openhands-agent-ygp","title":"P1 — Foundations: utils, logger, io, event model","description":"FOUNDATIONS — and the first real code, so it sets the workflow: TESTS + EXAMPLES FIRST (red/green). Port the relevant Python tests to vitest and the examples, watch them fail, then implement. Modules: utils, logger, io, event model (low-dependency leaves). Establishes the zod v4 patterns (pydantic BaseModel -\u003e zod schema + z.infer) and the event discriminated-union shape everything builds on. Serialization round-trip tests against Python JSON fixtures. Public API stays consistent with Python (adapted to TS idioms); clean APIs win. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P1 foundations implemented and verified. Covered zod v4 LLM message/content schemas needed by events; Python-compatible event schemas and eventsToMessages batching/user-message coalescing; ACP tool call and hook execution event parity helpers; utils for async callback wrapping, truncate/path/github/paging/command/redaction/json/datetime/display/deprecated-field handling; LocalFileStore/InMemoryFileStore/MemoryLRUCache; lightweight neutral logger with no Python/LiteLLM-specific default suppression. Secret-handling decision recorded for P2: do NOT port Python Cipher/plaintext/encrypted-at-rest split; implement keyring-backed SecretRef/SecretStore instead. LLM keys are provider-scoped by default under keyring service 'openhands', with explicit per-profile overrides for cases like multiple litellm_proxy profiles using different proxy keys. Verification: npm test, typecheck, lint, and build pass (47 tests).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.396859+02:00","updated_at":"2026-06-24T04:03:13.724783+02:00","closed_at":"2026-06-24T04:03:13.724783+02:00","dependencies":[{"issue_id":"openhands-agent-ygp","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.796883+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-w38","title":"P5 \u2014 Conversation + agent loop","description":"Conversation + agent loop. Transpile LocalConversation, RemoteConversation, ConversationState, the agent step loop, stuck detection. MUST include the multi-tool-use PENDING-ACTIONS QUEUE: when the LLM emits multiple tool calls, queue them as ActionEvents, execute (incl. parallel via ParallelToolExecutor equivalent), track unmatched actions (get_unmatched_actions), support cancellation/rejection of pending actions. This is core execution machinery (NOT confirmation) and is required. NO confirmation gate. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: Added RemoteConversation REST client slice. RemoteConversation now supports sendMessage without implicit run, run with optional blocking status polling, rejectPendingActions, pause, and interrupt over /api/conversations endpoints, with local executionStatus mirroring server terminal states. Verification after this slice: npm test, typecheck, lint, build pass (112 tests). P5 now covers ConversationState, LocalConversation, RemoteConversation, Agent step loop, stuck detection, pending/unmatched action queue, cancellation/rejection, and multi-tool parallel execution. Commits include 091c900, 38f112f, 56d9fa0, 9666ba6.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.619859+02:00","updated_at":"2026-06-24T05:57:53.318925+02:00","closed_at":"2026-06-24T05:57:53.318925+02:00","dependencies":[{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.105414+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.58951+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-2ba","type":"blocks","created_at":"2026-06-24T01:10:17.664653+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-ygp","title":"P1 \u2014 Foundations: utils, logger, io, event model","description":"FOUNDATIONS \u2014 and the first real code, so it sets the workflow: TESTS + EXAMPLES FIRST (red/green). Port the relevant Python tests to vitest and the examples, watch them fail, then implement. Modules: utils, logger, io, event model (low-dependency leaves). Establishes the zod v4 patterns (pydantic BaseModel -> zod schema + z.infer) and the event discriminated-union shape everything builds on. Serialization round-trip tests against Python JSON fixtures. Public API stays consistent with Python (adapted to TS idioms); clean APIs win. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P1 foundations implemented and verified. Covered zod v4 LLM message/content schemas needed by events; Python-compatible event schemas and eventsToMessages batching/user-message coalescing; ACP tool call and hook execution event parity helpers; utils for async callback wrapping, truncate/path/github/paging/command/redaction/json/datetime/display/deprecated-field handling; LocalFileStore/InMemoryFileStore/MemoryLRUCache; lightweight neutral logger with no Python/LiteLLM-specific default suppression. Secret-handling decision recorded for P2: do NOT port Python Cipher/plaintext/encrypted-at-rest split; implement keyring-backed SecretRef/SecretStore instead. LLM keys are provider-scoped by default under keyring service 'openhands', with explicit per-profile overrides for cases like multiple litellm_proxy profiles using different proxy keys. Verification: npm test, typecheck, lint, and build pass (47 tests).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.396859+02:00","updated_at":"2026-06-24T04:03:13.724783+02:00","closed_at":"2026-06-24T04:03:13.724783+02:00","dependencies":[{"issue_id":"openhands-agent-ygp","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.796883+02:00","created_by":"daemon"}]} diff --git a/examples/native-gemini-tools.ts b/examples/native-gemini-tools.ts new file mode 100644 index 0000000..746a93c --- /dev/null +++ b/examples/native-gemini-tools.ts @@ -0,0 +1,65 @@ +import { + Agent, + FinishTool, + LocalConversation, + ToolDefinition, + conversationExecutionStatus, + createClientFromProfile, + llmProfileSchema, +} from '@smolpaws/openhands-agent'; +import { z } from 'zod'; + +import { createExampleLlmSecretStore } from './_shared/exampleProfile.js'; + +const profile = llmProfileSchema.parse({ + profileId: 'native-gemini-tools-example', + providerId: 'gemini', + model: process.env.GEMINI_TOOL_MODEL?.trim() || 'gemini-3.5-flash-lite', + maxOutputTokens: 512, + reasoningEffort: 'low', +}); +const store = createExampleLlmSecretStore(profile); + +if (store === null) { + console.log('native-gemini-tools: set GEMINI_API_KEY to run this live tool-invocation example.'); +} else { + const calls: string[] = []; + const lookupValue = new ToolDefinition({ + name: 'lookup_value', + description: 'Return the exact verification value. Call this before finish.', + inputSchema: z.object({ key: z.literal('verification') }).strict(), + executor: async () => { + calls.push('lookup_value'); + return { value: 'GEMINI_NATIVE_TOOL_OK' }; + }, + }); + const conversation = new LocalConversation({ + agent: new Agent({ + llm: await createClientFromProfile(profile, store), + tools: [lookupValue, FinishTool.create()], + systemPrompt: 'Use native function tools, never textual imitations. Call lookup_value with key verification, then call finish with the returned value.', + }), + maxIterations: 5, + }); + + conversation.sendMessage('Look up the verification value and finish with it.'); + await conversation.run(); + + const actionNames = conversation.state.events + .filter((event) => event.kind === 'ActionEvent') + .map((event) => event.tool_name); + assert(conversation.state.executionStatus === conversationExecutionStatus.FINISHED, `conversation status was ${conversation.state.executionStatus}`); + assert(calls.includes('lookup_value'), 'lookup_value executor was not invoked'); + assert(actionNames.includes('finish'), 'finish was not invoked as a native tool'); + + console.log(JSON.stringify({ + example: 'native-gemini-tools', + model: profile.model, + execution_status: conversation.state.executionStatus, + native_action_tools: actionNames, + })); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} diff --git a/package.json b/package.json index 413ed22..ce082cd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "lint": "eslint ./src", "live:llm": "npm run build && node scripts/live/llm-smoke.mjs", "live:openai-tools": "npm run build && tsx examples/native-openai-tools.ts", + "live:gemini-tools": "npm run build && tsx examples/native-gemini-tools.ts", "live:openai-responses-reasoning": "npm run build && tsx scripts/live/openai-responses-reasoning.ts", "live:anthropic-cache-smoke": "npm run build && tsx scripts/live/anthropic-cache-smoke.ts", "live:provider-smokes": "npm run live:openai-responses-reasoning && npm run live:anthropic-cache-smoke", diff --git a/src/llm/__tests__/factory.test.ts b/src/llm/__tests__/factory.test.ts index ccc722b..f9c7091 100644 --- a/src/llm/__tests__/factory.test.ts +++ b/src/llm/__tests__/factory.test.ts @@ -37,7 +37,8 @@ describe('createClientFromProfile', () => { const response = await client.complete([{ role: 'user', content: [textContent('ping')] }]); expect(client).toBeInstanceOf(GeminiClient); - expect(calls[0]?.url).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent'); + expect(calls[0]?.url).toBe('https://generativelanguage.googleapis.com/v1beta/interactions'); + expect(calls[0]?.body).toMatchObject({ model: 'gemini-3.5-flash', store: false }); expect(calls[0]?.headers['x-goog-api-key']).toBe('gemini-key'); expect(response.message.content).toEqual([textContent('gemini pong')]); }); @@ -118,8 +119,10 @@ function fakeFetch(kind: 'anthropic' | 'gemini' | 'responses' | 'chat', calls: F } if (kind === 'gemini') { return { - candidates: [{ content: { role: 'model', parts: [{ text: 'gemini pong' }] } }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + id: 'interaction_1', + status: 'completed', + steps: [{ type: 'model_output', content: [{ type: 'text', text: 'gemini pong' }] }], + usage: { total_input_tokens: 1, total_output_tokens: 1, total_tokens: 2 }, }; } if (kind === 'responses') { diff --git a/src/llm/__tests__/gemini-client.test.ts b/src/llm/__tests__/gemini-client.test.ts index 5e4e284..e04d9cf 100644 --- a/src/llm/__tests__/gemini-client.test.ts +++ b/src/llm/__tests__/gemini-client.test.ts @@ -1,98 +1,172 @@ import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { InMemorySecretStore, llmProviderSecretRef } from '../../secrets/index.js'; +import { ToolDefinition } from '../../tool/index.js'; import { textContent } from '../index.js'; -import { GeminiClient, buildGeminiGenerateContentBody, createGeminiClientFromProfile, llmProfileSchema } from '../gemini.js'; +import { GeminiClient, buildGeminiInteractionsBody, createGeminiClientFromProfile, llmProfileSchema } from '../gemini.js'; -describe('profile-resolved Gemini client', () => { - it('resolves provider-scoped Gemini keys and constructs a client', async () => { - const profile = llmProfileSchema.parse({ profileId: 'gemini', providerId: 'gemini', model: 'gemini-2.5-pro' }); - const store = new InMemorySecretStore([[llmProviderSecretRef('gemini'), 'gemini-key']]); +const weatherTool = new ToolDefinition({ + name: 'get_weather', + description: 'Get weather for a location', + inputSchema: z.object({ location: z.string() }).strict(), + executor: async () => ({ weather: 'sunny' }), +}); - const client = await createGeminiClientFromProfile(profile, store, { fetch: fakeGeminiFetch({ text: 'ok' }) }); +describe('profile-resolved Gemini Interactions client', () => { + it('resolves provider-scoped keys and constructs a client', async () => { + const profile = llmProfileSchema.parse({ profileId: 'gemini', providerId: 'gemini', model: 'gemini-3.6-flash' }); + const store = new InMemorySecretStore([[llmProviderSecretRef('gemini'), 'gemini-key']]); + const client = await createGeminiClientFromProfile(profile, store, { fetch: fakeGeminiFetch(interactionWithText('ok')) }); expect(client).toBeInstanceOf(GeminiClient); expect(client.profile.providerId).toBe('gemini'); }); - it('posts Gemini generateContent requests and parses responses', async () => { + it('posts stateless Interactions requests and parses model output', async () => { const profile = llmProfileSchema.parse({ profileId: 'gemini', providerId: 'gemini', - model: 'gemini-2.5-pro', - temperature: 0.3, - topP: 0.8, - topK: 40, + model: 'gemini-3.6-flash', maxOutputTokens: 2048, headers: { 'X-Goog-Request-Reason': 'test' }, }); const store = new InMemorySecretStore([[llmProviderSecretRef('gemini'), 'gemini-key']]); const calls: FakeFetchCall[] = []; - const client = await createGeminiClientFromProfile(profile, store, { fetch: fakeGeminiFetch({ text: 'pong' }, calls) }); + const client = await createGeminiClientFromProfile(profile, store, { fetch: fakeGeminiFetch(interactionWithText('pong'), calls) }); const result = await client.complete([ { role: 'system', content: [textContent('You are terse.')] }, { role: 'user', content: [textContent('Ping?')] }, ]); - expect(calls).toHaveLength(1); - expect(calls[0]?.url).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent'); + expect(calls[0]?.url).toBe('https://generativelanguage.googleapis.com/v1beta/interactions'); expect(calls[0]?.headers['x-goog-api-key']).toBe('gemini-key'); expect(calls[0]?.headers['x-goog-request-reason']).toBe('test'); - expect(calls[0]?.body).toMatchObject({ - systemInstruction: { parts: [{ text: 'You are terse.' }] }, - contents: [{ role: 'user', parts: [{ text: 'Ping?' }] }], - generationConfig: { - temperature: 0.3, - topP: 0.8, - topK: 40, - maxOutputTokens: 2048, - }, + expect(calls[0]?.body).toEqual({ + model: 'gemini-3.6-flash', + store: false, + system_instruction: 'You are terse.', + input: [{ type: 'user_input', content: [{ type: 'text', text: 'Ping?' }] }], + generation_config: { max_output_tokens: 2048 }, }); - expect(result.message.role).toBe('assistant'); expect(result.message.content).toEqual([textContent('pong')]); expect(result.usage).toEqual({ promptTokens: 13, completionTokens: 8, totalTokens: 21 }); }); it('requires a keyring-backed API key', async () => { - const profile = llmProfileSchema.parse({ profileId: 'gemini', providerId: 'gemini', model: 'gemini-2.5-pro' }); - + const profile = llmProfileSchema.parse({ profileId: 'gemini', providerId: 'gemini', model: 'gemini-3.6-flash' }); await expect(createGeminiClientFromProfile(profile, new InMemorySecretStore())).rejects.toThrow( /Missing API key for Gemini LLM profile 'gemini'/u, ); }); +}); - it('maps reasoning effort to thinkingConfig and round-trips thought signatures on function calls', async () => { - const profile = llmProfileSchema.parse({ - profileId: 'gemini', - providerId: 'gemini', - model: 'gemini-3-pro', - reasoningEffort: 'high', +describe('Gemini Interactions native tool calling', () => { + const profile = llmProfileSchema.parse({ + profileId: 'gemini-tools', + providerId: 'gemini', + model: 'gemini-3.6-flash', + reasoningEffort: 'high', + }); + + it('serializes flat function tools and strips unsupported schema fields', () => { + const body = buildGeminiInteractionsBody( + profile, + [{ role: 'user', content: [textContent('What is the weather?')] }], + [weatherTool], + ); + + expect(body.tools).toEqual([{ + type: 'function', + name: 'get_weather', + description: 'Get weather for a location', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }]); + expect(body.generation_config).toEqual({ + thinking_level: 'high', + thinking_summaries: 'auto', + tool_choice: 'auto', }); + }); + + it('omits tools and tool choice when no tools are supplied', () => { + const body = buildGeminiInteractionsBody(profile, [{ role: 'user', content: [textContent('Hello')] }], []); + + expect(body).not.toHaveProperty('tools'); + expect(body.generation_config).not.toHaveProperty('tool_choice'); + }); + + it('parses signed thought and parallel function-call steps', async () => { const store = new InMemorySecretStore([[llmProviderSecretRef('gemini'), 'gemini-key']]); const client = await createGeminiClientFromProfile(profile, store, { - fetch: fakeGeminiPartsFetch([ - { text: 'private plan', thought: true, thoughtSignature: 'thought_sig_123' }, - { functionCall: { name: 'lookup', args: { query: 'x' } } }, - ]), + fetch: fakeGeminiFetch({ + id: 'interaction_1', + status: 'requires_action', + steps: [ + { type: 'thought', signature: 'thought_sig_123', summary: [{ type: 'text', text: 'Check both cities.' }] }, + { type: 'function_call', id: 'call_1', name: 'get_weather', arguments: { location: 'Boston' } }, + { type: 'function_call', id: 'call_2', name: 'get_weather', arguments: { location: 'Paris' } }, + ], + usage: { total_input_tokens: 20, total_output_tokens: 9, total_tokens: 35 }, + }), }); - const result = await client.complete([{ role: 'user', content: [textContent('call a tool')] }]); - const body = buildGeminiGenerateContentBody(profile, [result.message]); + const result = await client.complete([{ role: 'user', content: [textContent('Weather in Boston and Paris?')] }], [weatherTool]); - expect(result.message.reasoning_content).toBe('private plan'); - expect(result.message.thinking_blocks).toEqual([{ type: 'thinking', thinking: 'private plan', signature: 'thought_sig_123' }]); + expect(result.message.reasoning_content).toBe('Check both cities.'); + expect(result.message.thinking_blocks).toEqual([ + { type: 'thinking', thinking: 'Check both cities.', signature: 'thought_sig_123' }, + ]); expect(result.message.tool_calls).toEqual([ - { id: 'gemini_call_1', responses_item_id: null, name: 'lookup', arguments: '{"query":"x"}', origin: 'completion' }, + { id: 'call_1', responses_item_id: null, name: 'get_weather', arguments: '{"location":"Boston"}', origin: 'completion' }, + { id: 'call_2', responses_item_id: null, name: 'get_weather', arguments: '{"location":"Paris"}', origin: 'completion' }, ]); - expect(body.generationConfig).toMatchObject({ thinkingConfig: { thinkingLevel: 'HIGH', includeThoughts: true } }); - expect(body.contents).toEqual([ + expect(result.usage).toEqual({ promptTokens: 20, completionTokens: 9, totalTokens: 35 }); + }); + + it('replays thought signatures, function calls, and tool results as stateless steps', () => { + const body = buildGeminiInteractionsBody(profile, [ + { role: 'user', content: [textContent('Weather in Boston?')] }, + { + role: 'assistant', + content: [textContent('I will check.')], + reasoning_content: 'Use the weather tool.', + thinking_blocks: [{ type: 'thinking', thinking: 'Use the weather tool.', signature: 'thought_sig_123' }], + tool_calls: [ + { id: 'call_1', responses_item_id: null, name: 'get_weather', arguments: '{"location":"Boston"}', origin: 'completion' }, + ], + }, + { role: 'tool', tool_call_id: 'call_1', name: 'get_weather', content: [textContent('{"weather":"rain"}')] }, + ], [weatherTool]); + + expect(body.input).toEqual([ + { type: 'user_input', content: [{ type: 'text', text: 'Weather in Boston?' }] }, + { type: 'thought', signature: 'thought_sig_123', summary: [{ type: 'text', text: 'Use the weather tool.' }] }, + { type: 'model_output', content: [{ type: 'text', text: 'I will check.' }] }, + { type: 'function_call', id: 'call_1', name: 'get_weather', arguments: { location: 'Boston' } }, { - role: 'model', - parts: [{ functionCall: { name: 'lookup', args: { query: 'x' } }, thoughtSignature: 'thought_sig_123' }], + type: 'function_result', + call_id: 'call_1', + name: 'get_weather', + result: [{ type: 'text', text: '{"weather":"rain"}' }], }, ]); }); + + it('rejects malformed replay arguments', () => { + expect(() => buildGeminiInteractionsBody(profile, [{ + role: 'assistant', + content: [], + tool_calls: [ + { id: 'call_bad', responses_item_id: null, name: 'get_weather', arguments: 'not-json', origin: 'completion' }, + ], + }], [weatherTool])).toThrow(/Gemini function call 'call_bad'.*valid JSON object/u); + }); }); interface FakeFetchCall { @@ -101,42 +175,31 @@ interface FakeFetchCall { readonly body: Record; } -function fakeGeminiFetch(response: { text: string }, calls: FakeFetchCall[] = []) { +function interactionWithText(text: string): Record { + return { + id: 'interaction_text', + status: 'completed', + steps: [{ type: 'model_output', content: [{ type: 'text', text }] }], + usage: { total_input_tokens: 13, total_output_tokens: 8, total_tokens: 21 }, + }; +} + +function fakeGeminiFetch(response: Record, calls: FakeFetchCall[] = []) { return async (url: string, init: { headers: Readonly>; body: string }) => { calls.push({ url, headers: normalizeHeaders(init.headers), body: JSON.parse(init.body) as Record }); return { ok: true, status: 200, async json() { - return { - candidates: [{ content: { role: 'model', parts: [{ text: response.text }] } }], - usageMetadata: { promptTokenCount: 13, candidatesTokenCount: 8, totalTokenCount: 21 }, - }; + return response; }, async text() { - return JSON.stringify(await this.json()); + return JSON.stringify(response); }, }; }; } - -function fakeGeminiPartsFetch(parts: readonly Record[]) { - return async () => ({ - ok: true, - status: 200, - async json() { - return { - candidates: [{ content: { role: 'model', parts } }], - usageMetadata: { promptTokenCount: 13, candidatesTokenCount: 8, totalTokenCount: 21 }, - }; - }, - async text() { - return JSON.stringify(await this.json()); - }, - }); -} - function normalizeHeaders(headers: Readonly>): Record { const normalized: Record = {}; for (const [key, value] of Object.entries(headers)) { diff --git a/src/llm/gemini.ts b/src/llm/gemini.ts index 622890b..be339a3 100644 --- a/src/llm/gemini.ts +++ b/src/llm/gemini.ts @@ -2,9 +2,9 @@ import { z } from 'zod'; import { getLlmApiKey } from '../secrets/index.js'; import type { SecretStore } from '../secrets/index.js'; +import type { JsonObject, ToolDefinition } from '../tool/index.js'; import { llmCompletionResponseSchema, type FetchLike, type LLMClient, type LLMCompletionResponse } from './client.js'; import { contentToString, messageSchema, type Content, type LLMProfile, type Message, type MessageToolCall } from './index.js'; -import { normalizeGenerationParamsForModel, toGeminiThinkingLevel } from './provider-quirks.js'; export { llmProfileSchema } from './index.js'; export type { LLMProfile } from './index.js'; @@ -26,19 +26,19 @@ export class GeminiClient implements LLMClient { this.fetchImpl = fetchImpl; } - async complete(messages: readonly Message[]): Promise { - const response = await this.fetchImpl(`${resolveBaseUrl(this.profile)}/models/${encodeURIComponent(this.profile.model)}:generateContent`, { + async complete(messages: readonly Message[], tools?: readonly ToolDefinition[]): Promise { + const response = await this.fetchImpl(`${resolveBaseUrl(this.profile)}/interactions`, { method: 'POST', headers: buildHeaders(this.profile, this.apiKey), - body: JSON.stringify(buildGeminiGenerateContentBody(this.profile, messages)), + body: JSON.stringify(buildGeminiInteractionsBody(this.profile, messages, tools)), }); if (!response.ok) { const text = await response.text(); - throw new Error(`Gemini generateContent failed with HTTP ${response.status}: ${text}`); + throw new Error(`Gemini Interactions completion failed with HTTP ${response.status}: ${text}`); } - return parseGeminiGenerateContentResponse(await response.json()); + return parseGeminiInteractionResponse(await response.json()); } } @@ -63,109 +63,185 @@ export async function createGeminiClientFromProfile( return new GeminiClient(profile, apiKey, options.fetch ?? defaultFetch); } -export function buildGeminiGenerateContentBody(profile: LLMProfile, messages: readonly Message[]): Record { - const normalizedProfile = normalizeGenerationParamsForModel(profile); +export function buildGeminiInteractionsBody( + profile: LLMProfile, + messages: readonly Message[], + tools: readonly ToolDefinition[] = [], +): Record { + assertSupportedGenerationParams(profile); const parsedMessages = messages.map((message) => messageSchema.parse(message)); - const system = parsedMessages.filter((message) => message.role === 'system').flatMap((message) => contentToString(message.content)); + const systemInstruction = parsedMessages + .filter((message) => message.role === 'system') + .flatMap((message) => contentToString(message.content)) + .join('\n'); const body: Record = { - contents: parsedMessages.filter((message) => message.role !== 'system').map(toGeminiContent), + model: profile.model, + store: false, + input: parsedMessages + .filter((message) => message.role !== 'system') + .flatMap(toGeminiInteractionSteps), }; - if (system.length > 0) { - body.systemInstruction = { parts: system.map((text) => ({ text })) }; + if (systemInstruction.length > 0) { + body.system_instruction = systemInstruction; + } + if (tools.length > 0) { + body.tools = tools.map(toGeminiInteractionTool); } - const generationConfig = buildGenerationConfig(normalizedProfile); + const generationConfig = buildGenerationConfig(profile, tools.length > 0); if (Object.keys(generationConfig).length > 0) { - body.generationConfig = generationConfig; + body.generation_config = generationConfig; } return body; } -function toGeminiContent(message: Message): Record { - if (message.role === 'tool') { - return { - role: 'user', - parts: [{ functionResponse: { name: message.name ?? 'unknown_tool', response: { content: contentToString(message.content).join('\n') } } }], - }; +function assertSupportedGenerationParams(profile: LLMProfile): void { + const unsupported = [ + ['temperature', profile.temperature], + ['topP', profile.topP], + ['topK', profile.topK], + ].filter((entry): entry is [string, number] => entry[1] !== null); + if (unsupported.length > 0) { + throw new Error( + `Gemini Interactions does not support profile fields: ${unsupported.map(([name]) => name).join(', ')}.`, + ); } +} + +function buildGenerationConfig(profile: LLMProfile, hasTools: boolean): Record { + const config: Record = {}; + if (profile.maxOutputTokens !== null) { + config.max_output_tokens = profile.maxOutputTokens; + } + if (profile.reasoningEffort !== null) { + config.thinking_level = profile.reasoningEffort; + config.thinking_summaries = 'auto'; + } + if (hasTools) { + config.tool_choice = 'auto'; + } + return config; +} + +function toGeminiInteractionTool(tool: ToolDefinition): Record { + const responsesTool = tool.toResponsesTool(); return { - role: message.role === 'assistant' ? 'model' : 'user', - parts: toGeminiParts(message), + type: 'function', + name: responsesTool.name, + description: responsesTool.description, + parameters: stripUnsupportedSchemaProperties(responsesTool.parameters), }; } -function toGeminiParts(message: Message): readonly Record[] { - const signature = firstThinkingSignature(message); - const parts = message.content.flatMap((content) => { - if (content.type === 'text' && content.text.length === 0) { - return []; +function stripUnsupportedSchemaProperties(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stripUnsupportedSchemaProperties); + } + if (!isJsonObject(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== '$schema' && key !== 'additionalProperties') + .map(([key, child]) => [key, stripUnsupportedSchemaProperties(child)]), + ); +} + +function toGeminiInteractionSteps(message: Message): readonly Record[] { + if (message.role === 'user') { + return [{ type: 'user_input', content: toGeminiContent(message.content) }]; + } + if (message.role === 'tool') { + const step: Record = { + type: 'function_result', + call_id: message.tool_call_id ?? '', + result: toGeminiContent(message.content), + }; + if (message.name !== null) { + step.name = message.name; } - return [toGeminiPart(content, signature)]; - }); + return [step]; + } + + const steps: Record[] = []; + for (const block of message.thinking_blocks) { + if (block.type !== 'thinking') { + continue; + } + const step: Record = { + type: 'thought', + summary: block.thinking.length === 0 ? [] : [{ type: 'text', text: block.thinking }], + }; + if (block.signature !== null) { + step.signature = block.signature; + } + steps.push(step); + } + const content = toGeminiContent(message.content); + if (content.length > 0) { + steps.push({ type: 'model_output', content }); + } if (message.tool_calls !== null) { - parts.push(...message.tool_calls.map((toolCall, index) => toGeminiFunctionCallPart(toolCall, index === 0 ? signature : null))); + steps.push(...message.tool_calls.map(toGeminiFunctionCallStep)); } - return parts; + return steps; } -function toGeminiPart(content: Content, thoughtSignature: string | null): Record { - if (content.type === 'text') { - const part: Record = { text: content.text }; - if (thoughtSignature !== null) { - part.thoughtSignature = thoughtSignature; +function toGeminiContent(content: readonly Content[]): readonly Record[] { + const result: Record[] = []; + for (const item of content) { + if (item.type === 'text') { + if (item.text.length > 0) { + result.push({ type: 'text', text: item.text }); + } + } else { + result.push(...item.image_urls.map((uri) => ({ type: 'image', uri }))); } - return part; } - return { fileData: { fileUri: content.image_urls[0] ?? '' } }; + return result; } -function toGeminiFunctionCallPart(toolCall: MessageToolCall, thoughtSignature: string | null): Record { - const part: Record = { functionCall: { name: toolCall.name, args: parseToolArguments(toolCall.arguments) } }; - if (thoughtSignature !== null) { - part.thoughtSignature = thoughtSignature; - } - return part; +function toGeminiFunctionCallStep(toolCall: MessageToolCall): Record { + return { + type: 'function_call', + id: toolCall.id, + name: toolCall.name, + arguments: parseFunctionCallArguments(toolCall), + }; } -function buildGenerationConfig(profile: LLMProfile): Record { - const config: Record = {}; - if (profile.temperature !== null) { - config.temperature = profile.temperature; - } - if (profile.topP !== null) { - config.topP = profile.topP; - } - if (profile.topK !== null) { - config.topK = profile.topK; - } - if (profile.maxOutputTokens !== null) { - config.maxOutputTokens = profile.maxOutputTokens; +function parseFunctionCallArguments(toolCall: MessageToolCall): JsonObject { + let parsed: unknown; + try { + parsed = JSON.parse(toolCall.arguments) as unknown; + } catch { + throw new Error(`Gemini function call '${toolCall.id}' arguments must be a valid JSON object.`); } - const thinkingLevel = toGeminiThinkingLevel(profile.reasoningEffort); - if (thinkingLevel !== undefined) { - config.thinkingConfig = { thinkingLevel, includeThoughts: true }; + if (!isJsonObject(parsed)) { + throw new Error(`Gemini function call '${toolCall.id}' arguments must be a valid JSON object.`); } - return config; + return parsed; } -function parseGeminiGenerateContentResponse(raw: unknown): LLMCompletionResponse { - const parsed = geminiGenerateContentResponseSchema.parse(raw); - const firstCandidate = parsed.candidates[0]; - if (firstCandidate === undefined) { - throw new Error('Gemini generateContent returned no candidates.'); - } - const parts = firstCandidate.content.parts; - const text = parts - .flatMap((part) => (part.text === undefined || part.text.length === 0 || part.thought === true ? [] : [part.text])) +function parseGeminiInteractionResponse(raw: unknown): LLMCompletionResponse { + const parsed = geminiInteractionResponseSchema.parse(raw); + const modelOutputSteps = parsed.steps.filter((step): step is GeminiModelOutputStep => step.type === 'model_output'); + const text = modelOutputSteps + .flatMap((step) => step.content) + .filter((content): content is GeminiTextContent => content.type === 'text') + .map((content) => content.text) .join('\n'); - const reasoningContent = parts - .flatMap((part) => (part.text === undefined || part.text.length === 0 || part.thought !== true ? [] : [part.text])) - .join(''); - const thoughtSignature = parts.find((part) => part.thoughtSignature !== undefined)?.thoughtSignature ?? null; - const toolCalls = parts - .flatMap((part, index) => (part.functionCall === undefined ? [] : [fromGeminiFunctionCall(part.functionCall, index)])); - const promptTokens = parsed.usageMetadata?.promptTokenCount ?? 0; - const completionTokens = parsed.usageMetadata?.candidatesTokenCount ?? 0; - const totalTokens = parsed.usageMetadata?.totalTokenCount ?? promptTokens + completionTokens; + const thoughtSteps = parsed.steps.filter((step): step is GeminiThoughtStep => step.type === 'thought'); + const thinkingBlocks = thoughtSteps.map((step) => { + const thinking = step.summary + .filter((content): content is GeminiTextContent => content.type === 'text') + .map((content) => content.text) + .join(''); + return { type: 'thinking' as const, thinking, signature: step.signature ?? null }; + }); + const reasoningContent = thinkingBlocks.map((block) => block.thinking).join(''); + const toolCalls = parsed.steps + .filter((step): step is GeminiFunctionCallStep => step.type === 'function_call') + .map(fromGeminiFunctionCallStep); return llmCompletionResponseSchema.parse({ message: { @@ -173,35 +249,23 @@ function parseGeminiGenerateContentResponse(raw: unknown): LLMCompletionResponse content: text, tool_calls: toolCalls.length > 0 ? toolCalls : null, reasoning_content: reasoningContent.length > 0 ? reasoningContent : null, - thinking_blocks: thoughtSignature === null - ? [] - : [{ type: 'thinking', thinking: reasoningContent, signature: thoughtSignature }], + thinking_blocks: thinkingBlocks, + }, + usage: parsed.usage === null ? null : { + promptTokens: parsed.usage.total_input_tokens, + completionTokens: parsed.usage.total_output_tokens, + totalTokens: parsed.usage.total_tokens, }, - usage: { promptTokens, completionTokens, totalTokens }, raw, }); } -function firstThinkingSignature(message: Message): string | null { - return message.thinking_blocks.find( - (block): block is Extract => block.type === 'thinking' && block.signature !== null, - )?.signature ?? null; -} - -function parseToolArguments(args: string): unknown { - try { - return JSON.parse(args) as unknown; - } catch { - return args; - } -} - -function fromGeminiFunctionCall(functionCall: GeminiFunctionCall, index: number): MessageToolCall { +function fromGeminiFunctionCallStep(step: GeminiFunctionCallStep): MessageToolCall { return { - id: `gemini_call_${index}`, + id: step.id, responses_item_id: null, - name: functionCall.name, - arguments: JSON.stringify(functionCall.args ?? {}), + name: step.name, + arguments: JSON.stringify(step.arguments), origin: 'completion', }; } @@ -225,41 +289,53 @@ async function defaultFetch( return globalThis.fetch(url, init); } -const geminiFunctionCallSchema = z - .object({ name: z.string(), args: z.unknown().optional() }) +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const geminiTextContentSchema = z.object({ type: z.literal('text'), text: z.string() }).passthrough(); +const geminiOtherContentSchema = z.object({ type: z.string() }).passthrough(); +const geminiContentSchema = z.union([geminiTextContentSchema, geminiOtherContentSchema]); +const geminiModelOutputStepSchema = z + .object({ type: z.literal('model_output'), content: z.array(geminiContentSchema).default([]) }) .passthrough(); -const geminiPartSchema = z +const geminiThoughtStepSchema = z .object({ - text: z.string().optional(), - thought: z.boolean().optional(), - thoughtSignature: z.string().optional(), - functionCall: geminiFunctionCallSchema.optional(), + type: z.literal('thought'), + signature: z.string().nullable().optional(), + summary: z.array(geminiContentSchema).default([]), }) .passthrough(); - -type GeminiFunctionCall = z.infer; - -const geminiGenerateContentResponseSchema = z +const geminiFunctionCallStepSchema = z + .object({ + type: z.literal('function_call'), + id: z.string(), + name: z.string(), + arguments: z.record(z.string(), z.unknown()), + }) + .passthrough(); +const geminiOtherStepSchema = z.object({ type: z.string() }).passthrough(); +const geminiStepSchema = z.union([ + geminiModelOutputStepSchema, + geminiThoughtStepSchema, + geminiFunctionCallStepSchema, + geminiOtherStepSchema, +]); +const geminiUsageSchema = z .object({ - candidates: z.array( - z - .object({ - content: z - .object({ - role: z.string().default('model'), - parts: z.array(geminiPartSchema).default([]), - }) - .passthrough(), - }) - .passthrough(), - ), - usageMetadata: z - .object({ - promptTokenCount: z.number().int().min(0).optional(), - candidatesTokenCount: z.number().int().min(0).optional(), - totalTokenCount: z.number().int().min(0).optional(), - }) - .passthrough() - .optional(), + total_input_tokens: z.number().int().min(0).default(0), + total_output_tokens: z.number().int().min(0).default(0), + total_tokens: z.number().int().min(0).default(0), }) .passthrough(); +const geminiInteractionResponseSchema = z + .object({ + steps: z.array(geminiStepSchema).default([]), + usage: geminiUsageSchema.nullable().default(null), + }) + .passthrough(); + +type GeminiTextContent = z.infer; +type GeminiModelOutputStep = z.infer; +type GeminiThoughtStep = z.infer; +type GeminiFunctionCallStep = z.infer; From 29b2319ab1c90d0cfef036d18270dcaea98ece0f Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Fri, 31 Jul 2026 21:17:33 +0200 Subject: [PATCH 4/7] test: verify native tools across compatible routes Prove that the existing OpenAI Chat Completions tool path works unchanged for OpenRouter, LiteLLM-compatible base URLs, and custom gateways, including assistant tool-call parsing and tool-result replay. Document the compatibility boundary: gateways must expose the standard Chat Completions function-tool dialect; the SDK does not guess nonstandard payloads or translate proxy traffic into native Anthropic/Gemini shapes. Co-authored-by: smolpaws Co-authored-by: openhands --- .beads/issues.jsonl | 2 +- README.md | 9 +- src/llm/__tests__/openai-client.test.ts | 121 ++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d6ecf0a..2b545d4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -30,7 +30,7 @@ {"id":"openhands-agent-tools-anthropic","title":"Implement Anthropic native tool calling","description":"Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.","notes":"Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T20:49:33.806344+02:00","labels":["anthropic","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T20:49:33.806344+02:00"} {"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Completed 2026-07-30: Migrated GeminiClient from legacy generateContent to current /v1beta/interactions in stateless store:false mode. Added flat ToolDefinition serialization, signed thought/model/function step replay, function_result continuation, parallel function_call parsing, Interactions usage parsing, schema compatibility stripping, focused tests, and a credential-gated native tool example. Live gemini-3.5-flash-lite run dispatched lookup_value then finish successfully. Full suite: 254 tests pass; typecheck, lint, build, and example typecheck pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:14:13.467478+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:14:13.467478+02:00"} {"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"OpenAI native tool passing is already done by PR #7; this epic tracks the remaining provider clients only.","status":"open","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","tools","transpile"]} -{"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Acceptance: tests cover OpenAI-compatible chat payload tools, no-tools omission, custom baseUrl/proxy behavior, OpenRouter behavior if supported, and clear docs for any disabled or unverified provider dialect.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Completed 2026-07-30: Confirmed the merged OpenAIChatClient path is the correct propagation implementation for OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible base URLs. Added route-level tests for endpoint/auth/tool payloads plus proxy tool-call and tool-result continuation round-trip. Documented that compatibility requires the standard Chat Completions function dialect and that native Anthropic/Gemini or nonstandard proxy dialect translation is not provided. Full suite: 257 tests pass; typecheck and lint pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:17:33.452730+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:17:33.452730+02:00"} {"id":"openhands-agent-tools-research","title":"Research provider-native tool APIs and bounded oh-tab reference","description":"Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.","notes":"Completed 2026-07-30: Research document at docs/NATIVE_TOOLS_RESEARCH.md covers Anthropic (tools array, tool_use blocks, tool_result), Gemini Interactions (type:function, function_call steps, function_result, stateful mode), OpenAI-compatible (reuse Chat shape, provider gating). Captured oh-tab patterns. 4-phase plan: Anthropic \u2192 Gemini + migration \u2192 OpenAI-compatible \u2192 validation.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T17:01:34.378353+02:00","labels":["anthropic","gemini","llm","openai-compatible","research","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-research","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T17:01:34.378353+02:00"} {"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Acceptance: npm test/typecheck/lint/build pass, provider-specific unit tests prove real serialization/parsing code paths, docs mention OpenAI done in PR #7, and examples/live smokes are opt-in with existing secret conventions.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} {"id":"openhands-agent-w38","title":"P5 \u2014 Conversation + agent loop","description":"Conversation + agent loop. Transpile LocalConversation, RemoteConversation, ConversationState, the agent step loop, stuck detection. MUST include the multi-tool-use PENDING-ACTIONS QUEUE: when the LLM emits multiple tool calls, queue them as ActionEvents, execute (incl. parallel via ParallelToolExecutor equivalent), track unmatched actions (get_unmatched_actions), support cancellation/rejection of pending actions. This is core execution machinery (NOT confirmation) and is required. NO confirmation gate. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: Added RemoteConversation REST client slice. RemoteConversation now supports sendMessage without implicit run, run with optional blocking status polling, rejectPendingActions, pause, and interrupt over /api/conversations endpoints, with local executionStatus mirroring server terminal states. Verification after this slice: npm test, typecheck, lint, build pass (112 tests). P5 now covers ConversationState, LocalConversation, RemoteConversation, Agent step loop, stuck detection, pending/unmatched action queue, cancellation/rejection, and multi-tool parallel execution. Commits include 091c900, 38f112f, 56d9fa0, 9666ba6.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.619859+02:00","updated_at":"2026-06-24T05:57:53.318925+02:00","closed_at":"2026-06-24T05:57:53.318925+02:00","dependencies":[{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.105414+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.58951+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-2ba","type":"blocks","created_at":"2026-06-24T01:10:17.664653+02:00","created_by":"daemon"}]} diff --git a/README.md b/README.md index 8719578..5a1a278 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,18 @@ Accepted deviations are deliberate and should not be treated as missing work unl - **Type enforcement.** Strict TypeScript everywhere; runtime validation via [zod v4](https://github.com/colinhacks/zod) (replacing pydantic), using its native `z.toJSONSchema()` for tool/settings schema generation. - **Fresh transpilation.** We do **not** copy existing code. The earlier TS attempt in `oh-tab` is outdated and serves only as a reference for product-level profile semantics, tooling, and tests. - **Profile-first product LLM boundary.** Product and REST callers select an `LLMProfile`; they do not pass raw Python-style `LLM` objects or loose model/provider fields. Low-level provider clients remain available as explicit advanced SDK/test building blocks. -- **API-native provider clients.** Implement provider APIs as they actually are — OpenAI-compatible Chat Completions, OpenAI Responses, Anthropic Messages, and Gemini GenerateContent — rather than flattening provider-specific reasoning, caching, tool-call, and replay behavior into a leaky abstraction. +- **API-native provider clients.** Implement provider APIs as they actually are — OpenAI-compatible Chat Completions, OpenAI Responses, Anthropic Messages, and Gemini Interactions — rather than flattening provider-specific reasoning, caching, tool-call, and replay behavior into a leaky abstraction. - **Host-owned profile persistence.** This package validates and consumes `LLMProfile` records but does not choose a global local profile database/path. Host products persist profile JSON in their own settings stores and pass selected profiles to the SDK. - **Lower-risk secret handling.** Do not port Python's plaintext/local plus encrypted-at-rest remote secret stack. Persist secret references only; store actual secret values in the OS keyring under the `openhands` service. LLM API keys are provider-scoped by default, with per-profile overrides for cases like multiple proxy profiles for the same provider. - **Tooling parity with `oh-tab`.** Same npm/build/test stack (tsup, vitest, eslint type-checked) unless there's a good reason to diverge. +## OpenAI-compatible native tools + +`providerId: 'openrouter'`, `providerId: 'litellm_proxy'`, and custom OpenAI-compatible `baseUrl` profiles use `OpenAIChatClient`. Native tools are sent with the standard Chat Completions function shape, and tool calls/results use assistant `tool_calls` plus `role: 'tool'` messages. OpenRouter's standard endpoint and configurable LiteLLM-compatible/custom base URLs are covered by transport tests. + +Compatibility here means the endpoint accepts the OpenAI Chat Completions dialect at `/chat/completions` with bearer authentication. The SDK does not translate tools into an upstream provider's native Anthropic or Gemini dialect when that provider sits behind a proxy, and it does not guess nonstandard proxy payloads. Configure such gateways to expose the Chat Completions function-tool contract or provide a provider-specific adapter. + + ## Tooling | Concern | Choice | diff --git a/src/llm/__tests__/openai-client.test.ts b/src/llm/__tests__/openai-client.test.ts index aa0ff49..63a9e2e 100644 --- a/src/llm/__tests__/openai-client.test.ts +++ b/src/llm/__tests__/openai-client.test.ts @@ -204,6 +204,91 @@ describe('OpenAI native tool serialization', () => { }); }); +describe('OpenAI-compatible native tool routes', () => { + const tool = new ToolDefinition({ + name: 'lookup_value', + description: 'Look up a value by key.', + inputSchema: z.object({ key: z.string() }).strict(), + }); + + it.each([ + { + name: 'OpenRouter', + profile: { profileId: 'openrouter-tools', providerId: 'openrouter', model: 'openai/gpt-4.1' }, + expectedUrl: 'https://openrouter.ai/api/v1/chat/completions', + }, + { + name: 'LiteLLM-compatible custom proxy', + profile: { + profileId: 'litellm-tools', + providerId: 'litellm_proxy', + model: 'openai/gpt-4.1', + baseUrl: 'https://llm-proxy.example.test/v1/', + }, + expectedUrl: 'https://llm-proxy.example.test/v1/chat/completions', + }, + ])('sends Chat Completions tools through $name', async ({ profile: rawProfile, expectedUrl }) => { + const profile = llmProfileSchema.parse(rawProfile); + const calls: FakeFetchCall[] = []; + const store = new InMemorySecretStore([[llmProviderSecretRef(profile.providerId), 'proxy-key']]); + const client = await createOpenAIChatClientFromProfile(profile, store, { fetch: fakeFetch({ content: 'ok' }, calls) }); + + await client.complete([{ role: 'user', content: [textContent('Look it up.')] }], [tool]); + + expect(calls[0]?.url).toBe(expectedUrl); + expect(calls[0]?.headers.authorization).toBe('Bearer proxy-key'); + expect(calls[0]?.body.tools).toEqual([{ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.toResponsesTool().parameters, + strict: false, + }, + }]); + }); + + it('round-trips proxy tool calls and results using Chat Completions messages', async () => { + const profile = llmProfileSchema.parse({ + profileId: 'custom-proxy-tools', + providerId: 'custom_gateway', + model: 'gpt-4.1', + baseUrl: 'https://gateway.example.test/openai/v1', + }); + const calls: FakeFetchCall[] = []; + const client = new OpenAIChatClient(profile, 'proxy-key', fakeToolCallFetch(calls)); + const userMessage = { role: 'user' as const, content: [textContent('Look it up.')] }; + + const result = await client.complete([userMessage], [tool]); + await client.complete([ + userMessage, + result.message, + { role: 'tool', tool_call_id: 'call_proxy_1', name: 'lookup_value', content: [textContent('{"value":"ok"}')] }, + ], [tool]); + + expect(result.message.tool_calls).toEqual([{ + id: 'call_proxy_1', + responses_item_id: null, + name: 'lookup_value', + arguments: '{"key":"verification"}', + origin: 'completion', + }]); + expect(calls[1]?.body.messages).toEqual([ + { role: 'user', content: 'Look it up.' }, + { + role: 'assistant', + tool_calls: [{ + id: 'call_proxy_1', + type: 'function', + function: { name: 'lookup_value', arguments: '{"key":"verification"}' }, + }], + }, + { role: 'tool', content: '{"value":"ok"}', tool_call_id: 'call_proxy_1', name: 'lookup_value' }, + ]); + }); +}); + + describe('OpenAI chat message serialization parity', () => { it('drops empty assistant content when tool calls are present', () => { const profile = llmProfileSchema.parse({ profileId: 'default', providerId: 'openai', model: 'gpt-5.1' }); @@ -439,6 +524,42 @@ function fakeFetch(response: { content: string }, calls: FakeFetchCall[] = []) { }; } +function fakeToolCallFetch(calls: FakeFetchCall[]) { + return async (url: string, init: { headers?: HeadersInit; body?: BodyInit | null }) => { + calls.push({ + url, + headers: normalizeHeaders(init.headers), + body: JSON.parse(String(init.body)) as Record, + }); + return { + ok: true, + status: 200, + async json() { + return { + choices: [{ + index: 0, + finish_reason: 'tool_calls', + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_proxy_1', + type: 'function', + function: { name: 'lookup_value', arguments: '{"key":"verification"}' }, + }], + }, + }], + usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 }, + }; + }, + async text() { + return JSON.stringify(await this.json()); + }, + }; + }; +} + + function fakeResponsesFetch(response: { content: string }, calls: FakeFetchCall[] = []) { return async (url: string, init: { headers?: HeadersInit; body?: BodyInit | null }) => { calls.push({ From 168eb925d75a988b0ae4088121bf7e35001e2a03 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Fri, 31 Jul 2026 21:24:21 +0200 Subject: [PATCH 5/7] test: validate native tools across providers Add a shared ToolDefinition serialization matrix and keyless example across Chat Completions, Responses, Anthropic Messages, and Gemini Interactions. Tighten Gemini malformed-step and missing-call-id handling so invalid replay fails locally. Align architecture, research, reasoning, transpile, README, and repository notes with the completed provider flow and live-test policy. All 261 tests and release-quality checks pass; the credential-gated Gemini smoke succeeded with gemini-3.5-flash-lite and Anthropic was not called live. Co-authored-by: smolpaws Co-authored-by: openhands --- .beads/issues.jsonl | 4 +- AGENTS.md | 6 +-- README.md | 8 ++-- docs/ARCHITECTURE.md | 10 +++-- docs/NATIVE_TOOLS_RESEARCH.md | 7 ++++ docs/REASONING_CAPABILITIES.md | 10 ++--- docs/TRANSPILE_PLAN.md | 2 +- examples/native-tool-serialization.ts | 52 ++++++++++++++++++++++++ src/llm/__tests__/gemini-client.test.ts | 21 ++++++++++ src/llm/__tests__/native-tools.test.ts | 54 +++++++++++++++++++++++++ src/llm/gemini.ts | 10 ++++- 11 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 examples/native-tool-serialization.ts create mode 100644 src/llm/__tests__/native-tools.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2b545d4..7ece995 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -29,9 +29,9 @@ {"id":"openhands-agent-mvm","title":"Fix examples GitHub environment OPENAI_API_KEY","description":"Manual examples workflow on main at e301a19 reached the real OpenAI profile path, but GitHub Actions failed with OpenAI HTTP 401 invalid_api_key. Code/local live run succeeded with the injected local credential, so the GitHub examples environment secret likely needs updating.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-07-05T08:36:48.625798+02:00","updated_at":"2026-07-06T05:32:50.995031+02:00","closed_at":"2026-07-06T05:32:50.995031+02:00","labels":["ci","examples","secrets"]} {"id":"openhands-agent-tools-anthropic","title":"Implement Anthropic native tool calling","description":"Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.","notes":"Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T20:49:33.806344+02:00","labels":["anthropic","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T20:49:33.806344+02:00"} {"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Completed 2026-07-30: Migrated GeminiClient from legacy generateContent to current /v1beta/interactions in stateless store:false mode. Added flat ToolDefinition serialization, signed thought/model/function step replay, function_result continuation, parallel function_call parsing, Interactions usage parsing, schema compatibility stripping, focused tests, and a credential-gated native tool example. Live gemini-3.5-flash-lite run dispatched lookup_value then finish successfully. Full suite: 254 tests pass; typecheck, lint, build, and example typecheck pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:14:13.467478+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:14:13.467478+02:00"} -{"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"OpenAI native tool passing is already done by PR #7; this epic tracks the remaining provider clients only.","status":"open","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["llm","tools","transpile"]} +{"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"Completed 2026-07-30: Non-OpenAI native tools now cover Anthropic Messages, Gemini Interactions, OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible routes. Provider beads and final validation are closed. Commits: research f710af3, Anthropic 09344cd, Gemini 41c9e46, compatible routes 29b2319, final validation pending this commit.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:24:13.322841+02:00","labels":["llm","tools","transpile"],"closed_at":"2026-07-31T21:24:13.322841+02:00"} {"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Completed 2026-07-30: Confirmed the merged OpenAIChatClient path is the correct propagation implementation for OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible base URLs. Added route-level tests for endpoint/auth/tool payloads plus proxy tool-call and tool-result continuation round-trip. Documented that compatibility requires the standard Chat Completions function dialect and that native Anthropic/Gemini or nonstandard proxy dialect translation is not provided. Full suite: 257 tests pass; typecheck and lint pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:17:33.452730+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:17:33.452730+02:00"} {"id":"openhands-agent-tools-research","title":"Research provider-native tool APIs and bounded oh-tab reference","description":"Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.","notes":"Completed 2026-07-30: Research document at docs/NATIVE_TOOLS_RESEARCH.md covers Anthropic (tools array, tool_use blocks, tool_result), Gemini Interactions (type:function, function_call steps, function_result, stateful mode), OpenAI-compatible (reuse Chat shape, provider gating). Captured oh-tab patterns. 4-phase plan: Anthropic \u2192 Gemini + migration \u2192 OpenAI-compatible \u2192 validation.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T17:01:34.378353+02:00","labels":["anthropic","gemini","llm","openai-compatible","research","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-research","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T17:01:34.378353+02:00"} -{"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Acceptance: npm test/typecheck/lint/build pass, provider-specific unit tests prove real serialization/parsing code paths, docs mention OpenAI done in PR #7, and examples/live smokes are opt-in with existing secret conventions.","status":"open","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-15T01:12:47.261769+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Completed 2026-07-30: Added cross-provider ToolDefinition matrix tests, a keyless serialization example, provider architecture/status documentation, explicit compatible-proxy limits, and Gemini malformed replay guards. Live Gemini Interactions tool dispatch passed with gemini-3.5-flash-lite; Anthropic made no live calls. Verification: 40 files / 261 tests pass; typecheck, lint, build, example/live typechecks, keyless test:examples, and npm pack --dry-run pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:24:13.322841+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:24:13.322841+02:00"} {"id":"openhands-agent-w38","title":"P5 \u2014 Conversation + agent loop","description":"Conversation + agent loop. Transpile LocalConversation, RemoteConversation, ConversationState, the agent step loop, stuck detection. MUST include the multi-tool-use PENDING-ACTIONS QUEUE: when the LLM emits multiple tool calls, queue them as ActionEvents, execute (incl. parallel via ParallelToolExecutor equivalent), track unmatched actions (get_unmatched_actions), support cancellation/rejection of pending actions. This is core execution machinery (NOT confirmation) and is required. NO confirmation gate. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: Added RemoteConversation REST client slice. RemoteConversation now supports sendMessage without implicit run, run with optional blocking status polling, rejectPendingActions, pause, and interrupt over /api/conversations endpoints, with local executionStatus mirroring server terminal states. Verification after this slice: npm test, typecheck, lint, build pass (112 tests). P5 now covers ConversationState, LocalConversation, RemoteConversation, Agent step loop, stuck detection, pending/unmatched action queue, cancellation/rejection, and multi-tool parallel execution. Commits include 091c900, 38f112f, 56d9fa0, 9666ba6.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.619859+02:00","updated_at":"2026-06-24T05:57:53.318925+02:00","closed_at":"2026-06-24T05:57:53.318925+02:00","dependencies":[{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.105414+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.58951+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-2ba","type":"blocks","created_at":"2026-06-24T01:10:17.664653+02:00","created_by":"daemon"}]} {"id":"openhands-agent-ygp","title":"P1 \u2014 Foundations: utils, logger, io, event model","description":"FOUNDATIONS \u2014 and the first real code, so it sets the workflow: TESTS + EXAMPLES FIRST (red/green). Port the relevant Python tests to vitest and the examples, watch them fail, then implement. Modules: utils, logger, io, event model (low-dependency leaves). Establishes the zod v4 patterns (pydantic BaseModel -> zod schema + z.infer) and the event discriminated-union shape everything builds on. Serialization round-trip tests against Python JSON fixtures. Public API stays consistent with Python (adapted to TS idioms); clean APIs win. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P1 foundations implemented and verified. Covered zod v4 LLM message/content schemas needed by events; Python-compatible event schemas and eventsToMessages batching/user-message coalescing; ACP tool call and hook execution event parity helpers; utils for async callback wrapping, truncate/path/github/paging/command/redaction/json/datetime/display/deprecated-field handling; LocalFileStore/InMemoryFileStore/MemoryLRUCache; lightweight neutral logger with no Python/LiteLLM-specific default suppression. Secret-handling decision recorded for P2: do NOT port Python Cipher/plaintext/encrypted-at-rest split; implement keyring-backed SecretRef/SecretStore instead. LLM keys are provider-scoped by default under keyring service 'openhands', with explicit per-profile overrides for cases like multiple litellm_proxy profiles using different proxy keys. Verification: npm test, typecheck, lint, and build pass (47 tests).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.396859+02:00","updated_at":"2026-06-24T04:03:13.724783+02:00","closed_at":"2026-06-24T04:03:13.724783+02:00","dependencies":[{"issue_id":"openhands-agent-ygp","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.796883+02:00","created_by":"daemon"}]} diff --git a/AGENTS.md b/AGENTS.md index 503d49f..df97566 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,6 @@ - Work is tracked in Beads (`bd`). Check open Beads before starting follow-up work. - The examples GitHub Environment now provides `OPENAI_API_KEY`, `GEMINI_API_KEY`, and `ANTHROPIC_API_KEY` to `.github/workflows/examples.yml`. - Use `createClientFromProfile(profile, store)` for generic LLM profile dispatch. It routes `providerId`/detected provider to Anthropic, Gemini, OpenAI Responses, or OpenAI-compatible chat. Product/REST callers should select `LLMProfile` records; use explicit provider factories such as `createOpenAIChatClientFromProfile` only for advanced SDK tests or provider-specific code. -- `Agent.step()` passes only usable `ToolDefinition` instances to `LLMClient.complete`; OpenAI clients serialize native tools and omit the request field when none are present. `npm run live:openai-tools` proves real read/edit/finish dispatch with `gpt-5-nano`. - -- Gemini `thoughtSignature` round-trip is verified live for Gemini 3.x models (`gemini-3.5-flash`, `gemini-3.1-pro-preview`) using `thinkingConfig.thinkingLevel`. Gemini 2.5 `thinkingBudget` support is intentionally closed as wont-fix because those models are old/unavailable for this SDK target. +- `Agent.step()` passes only usable `ToolDefinition` instances to `LLMClient.complete`; provider clients serialize native declarations and omit the request field when none are present. Anthropic uses `tool_use`/`tool_result`; Gemini uses stateless `/v1beta/interactions` step replay; OpenAI-compatible routes use the Chat Completions function dialect. +- `npm run live:openai-tools` proves real read/edit/finish dispatch with `gpt-5-nano`. `npm run live:gemini-tools` proves real `lookup_value`/finish dispatch and was verified with `gemini-3.5-flash-lite`. Anthropic tool tests use recorded shapes only because the key has no credit. +- Gemini signed thought round-trip now uses Interactions `thought` steps with lower-case `generation_config.thinking_level`; the previous GenerateContent `thinkingConfig` path is no longer used by `GeminiClient`. diff --git a/README.md b/README.md index 5a1a278..0740e77 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Idiomatic TypeScript transpilation of the [OpenHands](https://github.com/OpenHan ## Status -`0.3.3` is the native OpenAI tool-completion parity release of the fresh TypeScript transpilation. It covers the core SDK surfaces needed to build and run agent loops locally, passes usable Agent tools through OpenAI Chat Completions and Responses, and documents the main architecture in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md): +`0.3.3` established native OpenAI tool-completion parity. The current development line extends the same `ToolDefinition` flow through Anthropic Messages, Gemini Interactions, OpenRouter, LiteLLM-compatible endpoints, and custom OpenAI-compatible gateways. The main architecture is documented in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md): - zod-backed event, tool, settings, profile, and serialization models - profile-first LLM clients for OpenAI chat completions, OpenAI Responses, Anthropic, Gemini, and OpenAI-compatible profiles @@ -18,7 +18,7 @@ Intentional deviations from Python remain: no ACP runtime, security analyzers, r This package is tracking the Python `agent-sdk` architecture while staying idiomatic TypeScript. The implemented surfaces currently include focused parity coverage for: -- LLM message/content serialization, Agent-to-LLM `ToolDefinition` propagation, and provider-owned OpenAI chat completions/Responses, Anthropic, and Gemini request/response mapping +- LLM message/content serialization, Agent-to-LLM `ToolDefinition` propagation, and provider-owned OpenAI chat completions/Responses, Anthropic Messages, and Gemini Interactions request/response mapping - event schemas and `eventsToMessages` conversion, including parallel tool-call batching behavior - conversation state, local/remote conversations, pause/resume, restore, parallel execution, and stuck detection - settings/profiles, profile-selected LLM field hygiene, provider/profile-scoped API key references, and keyring-backed secret storage @@ -119,12 +119,14 @@ console.log(state.executionStatus); ## Examples -Runnable TypeScript examples live in [`examples/`](examples/) and are checked by `npm run test:examples`. Real-LLM examples use [`examples/_shared/exampleProfile.ts`](examples/_shared/exampleProfile.ts): by default set `OPENAI_API_KEY` to run them against an OpenAI LLM profile, or set `LLM_PROVIDER_ID`/`LLM_PROVIDER` and the matching `_API_KEY` env var to exercise another provider. The helper stores keys under `llmProviderSecretRef(profile.providerId)`, optionally overrides the model with `OPENAI_MODEL` or `LLM_MODEL`, and skips gracefully when no provider key is present. +Runnable TypeScript examples live in [`examples/`](examples/) and are checked by `npm run test:examples`. Real-LLM examples use [`examples/_shared/exampleProfile.ts`](examples/_shared/exampleProfile.ts): by default set `OPENAI_API_KEY` to run them against an OpenAI LLM profile, or set `LLM_PROVIDER_ID`/`LLM_PROVIDER` and the matching `_API_KEY` env var to exercise another provider. The helper stores keys under `llmProviderSecretRef(profile.providerId)`, optionally overrides the model with `OPENAI_MODEL` or `LLM_MODEL`, and skips gracefully when no provider key is present. `npm run live:gemini-tools` is the opt-in Gemini native-tool smoke; Anthropic tool coverage is recorded-shape/unit-only and makes no live request by default. | Example | Covers | |---------|--------| | [`hello-world.ts`](examples/hello-world.ts) | Real OpenAI profile completion through the shared env-backed example profile helper | | [`native-openai-tools.ts`](examples/native-openai-tools.ts) | Real OpenAI Responses read/edit/finish function calls through Agent tool dispatch | +| [`native-gemini-tools.ts`](examples/native-gemini-tools.ts) | Credential-gated Gemini Interactions tool dispatch; defaults to `gemini-3.5-flash-lite` | +| [`native-tool-serialization.ts`](examples/native-tool-serialization.ts) | Keyless comparison of one `ToolDefinition` across all four provider wire formats | | [`tools.ts`](examples/tools.ts) | Concrete terminal, file editor, glob, grep, and task tracker tools | | [`profiles-and-secrets.ts`](examples/profiles-and-secrets.ts) | Provider/profile-scoped LLM API key references and secret store usage | | [`agent-settings.ts`](examples/agent-settings.ts) | Agent settings/profile validation and profile-selected raw LLM field cleanup | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a54403..907ee23 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -111,7 +111,7 @@ Provider clients live next to the neutral model: - `OpenAIChatClient` for chat completions and compatible proxies. - `OpenAIResponsesClient` for the Responses API. - `AnthropicMessagesClient` for Anthropic Messages. -- `GeminiClient` for Gemini. +- `GeminiClient` for the Gemini Interactions API. The product boundary is profile-first: `createClientFromProfile(profile, secretStore)` resolves a concrete client from an `LLMProfile`. Product and REST callers select profiles; they do not instantiate a raw Python-style `LLM`, pass loose model/provider fields, or rely on implicit default models. Low-level provider clients and provider-specific factories remain exported only as explicit advanced SDK/test building blocks. @@ -127,10 +127,12 @@ The four provider APIs are implemented as the APIs they actually are, not hidden - OpenAI-compatible Chat Completions owns chat-completions request/response shape and compatible proxy behavior. - OpenAI Responses owns Responses-specific input, tool, reasoning, and replay fields. -- Anthropic Messages owns Anthropic content blocks, prompt caching, and extended-thinking details. -- Gemini owns GenerateContent parts, function-call parts, `thoughtSignature` round-tripping, and Gemini thinking config. +- Anthropic Messages owns Anthropic content blocks, prompt caching, extended-thinking details, `tool_use` calls, and `tool_result` continuation. +- Gemini owns Interactions steps, flat function tools, signed thought replay, `function_call` parsing, and `function_result` continuation. -For OpenAI, Chat Completions wraps the schema produced from `ToolDefinition.toResponsesTool()` in its nested function-tool shape, while Responses uses the helper's native top-level shape. Both omit the wire-level `tools` field when the supplied list is empty. These are provider-client concerns; the shared completion interface carries `ToolDefinition`s without a parallel DTO layer. +Every client receives the same optional `ToolDefinition[]` through `LLMClient.complete()` and derives its provider declaration from `ToolDefinition.toResponsesTool()`. Chat Completions wraps that schema in its nested function shape; Responses uses the top-level shape; Anthropic renames `parameters` to `input_schema`; Gemini uses a flat function declaration and removes JSON Schema fields its API rejects. All clients omit the wire-level `tools` field when the supplied list is empty. + +Gemini requests use documented stateless Interactions mode (`store: false`) and reconstruct `user_input`, `thought`, `model_output`, `function_call`, and `function_result` steps from the durable neutral transcript. This keeps conversation restore and forks correct instead of coupling an SDK client instance to server-side `previous_interaction_id` state. Signed thought steps round-trip through `Message.thinking_blocks`. `oh-tab/packages/agent-sdk` was used as inspiration for product-level profile semantics, key lookup shape, and build/test tooling expectations. It was not copied: the implementation is fresh TypeScript, and the older package remains reference-only. diff --git a/docs/NATIVE_TOOLS_RESEARCH.md b/docs/NATIVE_TOOLS_RESEARCH.md index f621479..37497e1 100644 --- a/docs/NATIVE_TOOLS_RESEARCH.md +++ b/docs/NATIVE_TOOLS_RESEARCH.md @@ -6,6 +6,13 @@ Research for beads: `openhands-agent-tools-research`, `openhands-agent-tools-nat This document captures provider-native tool API shapes for Anthropic Messages, Gemini Interactions API, and OpenAI-compatible clients, along with proven data-shape choices from the old oh-tab implementation. The goal is to wire `ToolDefinition[]` through each provider's `complete()` method. +## Implementation outcome + +The provider work is implemented in the current four-client architecture. Anthropic Messages uses native `tool_use`/`tool_result` blocks; Gemini uses `/v1beta/interactions` with typed steps and `store: false`; OpenRouter, LiteLLM-compatible endpoints, and custom gateways reuse the standard Chat Completions function dialect. Gemini intentionally uses stateless replay because the durable SDK event/message transcript—not mutable client-held `previous_interaction_id` state—must remain sufficient for conversation restore and forks. + +Provider unit tests cover request declarations, empty-tool omission, response calls, parallel calls, signed thinking, and result continuation. `examples/native-tool-serialization.ts` is keyless; OpenAI and Gemini live agent examples are credential-gated. Anthropic was not called live because the available key has no credit. + + ## Provider API Shapes ### Anthropic Messages API diff --git a/docs/REASONING_CAPABILITIES.md b/docs/REASONING_CAPABILITIES.md index 9afac5c..43bf662 100644 --- a/docs/REASONING_CAPABILITIES.md +++ b/docs/REASONING_CAPABILITIES.md @@ -18,7 +18,7 @@ The actual SDK path is `src/llm/`, not `packages/agent-sdk/src/sdk/llm/` in this | OpenAI Responses API | `src/llm/openai.ts` / `OpenAIResponsesClient` | Uses `/responses`; sends `reasoning.effort` and `reasoning.summary`. | | OpenAI-compatible Chat Completions | `src/llm/openai.ts` / `OpenAIChatClient` | Uses `/chat/completions`; direct OpenAI chat, OpenRouter, LiteLLM proxy, and OpenAI-compatible proxies currently share this transport. | | Anthropic Messages API | `src/llm/anthropic.ts` / `AnthropicMessagesClient` | Uses `/v1/messages`; current code derives `thinking: { type: 'enabled', budget_tokens }` from legacy `reasoningEffort`. | -| Gemini GenerateContent API | `src/llm/gemini.ts` / `GeminiClient` | Uses `:generateContent`; current code maps legacy `reasoningEffort` to `generationConfig.thinkingConfig.thinkingLevel`. Gemini Interactions is not implemented yet. | +| Gemini Interactions API | `src/llm/gemini.ts` / `GeminiClient` | Uses `/v1beta/interactions` with `store: false`; maps legacy `reasoningEffort` to lower-case `generation_config.thinking_level`, requests automatic thought summaries, and replays signed thought/function steps from the durable transcript. | `src/llm/factory.ts` resolves `providerId` / `baseUrl` to Anthropic, Gemini, OpenAI Responses, or OpenAI-compatible Chat. For `openrouter` and `litellm_proxy`, the current transport is OpenAI-compatible Chat, but the upstream model family may still be Anthropic, Gemini, or OpenAI. @@ -210,8 +210,8 @@ Anthropic and LiteLLM credentials/base URLs were not available in this environme | Anthropic modern adaptive models | `output_config.effort` and `thinking: { type: "adaptive" }` where applicable | effort values include `low`, `medium`, `high`, `xhigh`, `max`, with model restrictions: `xhigh` only on Fable 5, Mythos 5, Opus 4.8, Opus 4.7, Sonnet 5 per docs; `max` availability differs by model | Current code converts legacy `reasoningEffort` to manual `budget_tokens`, which is wrong for Fable 5, Opus 4.8, Sonnet 5, and deprecated for Sonnet/Opus 4.6. | | Anthropic manual thinking models | `thinking: { type: "enabled", budget_tokens, display? }` | explicit token budget less than `max_tokens`; display values include `summarized`, `omitted` where supported | Current code invents budget from legacy effort. Replace with explicit budget config. | | Anthropic task budgets beta | `output_config.task_budget` plus beta header | `{ type: "tokens", total, remaining? }` and opt-in beta header | Useful for future agent loops; should not be folded into a simple effort enum. | -| Gemini Interactions | `generation_config.thinking_level`, `generation_config.thinking_summaries` | lower-case model-specific values such as `minimal`, `low`, `medium`, `high`; exact set depends on model | Not implemented. It should be the target for Gemini reasoning continuity. | -| Gemini GenerateContent | `generationConfig.thinkingConfig` | mutually exclusive level or budget branches: level values `MINIMAL`, `LOW`, `MEDIUM`, `HIGH`, or a numeric `thinkingBudget`; `includeThoughts` may accompany either branch | Current code maps OpenAI low/medium/high to upper-case; misses `MINIMAL` and model-specific validation. | +| Gemini Interactions | `generation_config.thinking_level`, `generation_config.thinking_summaries` | lower-case model-specific values such as `minimal`, `low`, `medium`, `high`; exact set depends on model | Implemented with the legacy profile's `low | medium | high` subset and automatic summaries; model-specific capability validation remains future work. | +| Gemini GenerateContent | `generationConfig.thinkingConfig` | mutually exclusive level or budget branches: level values `MINIMAL`, `LOW`, `MEDIUM`, `HIGH`, or a numeric `thinkingBudget`; `includeThoughts` may accompany either branch | No longer used by `GeminiClient`; retained here only as research evidence for the distinct legacy API. | | LiteLLM proxy / OpenAI-compatible transport | transport remains OpenAI-compatible, but upstream model family comes from proxy alias/prefix/config | Resolve capabilities from upstream namespace: `anthropic/...`, `gemini/...`, `openai/...`, known `claude`/`gemini`/`gpt` aliases, or explicit profile metadata | Current factory treats `litellm_proxy` as generic OpenAI-compatible Chat, so it cannot expose native Anthropic/Gemini semantics. | @@ -411,8 +411,8 @@ Invalid reasoning config for gpt-5.6 via openai_chat_completions: reasoning.effo 1. Add the capability resolver and tests without changing request builders. 2. Add `reasoning` to the serializable `LLMProfile` schema and migration helpers. 3. Update OpenAI builders first because live evidence is complete for GPT-5.6. -4. Add Gemini Interactions as a new concrete client/API target; do not retrofit Interactions semantics into `GeminiClient`/GenerateContent. -5. Update GenerateContent to use provider-native `reasoning.api: 'gemini_generate_content'` only. +4. Extend the implemented Gemini Interactions transport with provider-native capability records; the tool-calling work migrated `GeminiClient` and did not retain a second GenerateContent client. +5. Keep GenerateContent capability evidence separate from Interactions; reintroduce a legacy client only if a concrete product requirement appears. 6. Update Anthropic after live credentials are available for the requested model set. 7. Add LiteLLM/OpenRouter upstream-family capability resolution; require explicit `upstreamProviderId` when aliases are ambiguous. diff --git a/docs/TRANSPILE_PLAN.md b/docs/TRANSPILE_PLAN.md index 380e151..3fe4f8a 100644 --- a/docs/TRANSPILE_PLAN.md +++ b/docs/TRANSPILE_PLAN.md @@ -18,7 +18,7 @@ The 0.2.0 parity line added compatibility/helper exports for smolpaws, profile-s Additional 0.3.x work added profile-first LLM client dispatch, live-provider hardening, EventLog/FileStore persistence, restore/idempotent seeding behavior, contiguous-index recovery, synchronous lock caveats, and the `FileStore.lockAsync()` follow-up with async EventLog/ConversationState/LocalConversation append paths for server/runtime code that may encounter lock contention. -The Agent → LLM tool-flow gap is closed: `Agent.step()` passes usable `ToolDefinition`s through the thin `LLMClient.complete()` boundary, and provider clients own native schema serialization. OpenAI Chat Completions and Responses coverage plus a live read/edit/finish example guard this pinned-Python behavior. Tool passing is parity work, not an accepted deviation. +The Agent → LLM tool-flow gap is closed across the four-client architecture: `Agent.step()` passes usable `ToolDefinition`s through the thin `LLMClient.complete()` boundary, and provider clients own native schema serialization. OpenAI Chat Completions/Responses, Anthropic Messages, Gemini Interactions, and OpenAI-compatible routes have provider-specific request/response tests. Credential-gated OpenAI and Gemini agent examples exercise real tool dispatch; Anthropic remains recorded-shape/unit-only because live credit is unavailable. Tool passing is parity work, not an accepted deviation. Accepted clarification: low-level LLM client classes may remain exported from the npm package as advanced/testing/building blocks. The product/REST boundary must still be **profile-only**: REST callers select LLM profiles, never raw clients or a Python-style bare `LLM` object. diff --git a/examples/native-tool-serialization.ts b/examples/native-tool-serialization.ts new file mode 100644 index 0000000..27eb3bf --- /dev/null +++ b/examples/native-tool-serialization.ts @@ -0,0 +1,52 @@ +import { + ToolDefinition, + buildAnthropicMessagesBody, + buildChatCompletionsBody, + buildGeminiInteractionsBody, + buildOpenAIResponsesBody, + llmProfileSchema, + messageSchema, + textContent, +} from '@smolpaws/openhands-agent'; +import { z } from 'zod'; + +const tool = new ToolDefinition({ + name: 'lookup_value', + description: 'Look up a value by key.', + inputSchema: z.object({ key: z.string() }).strict(), +}); +const messages = [messageSchema.parse({ role: 'user', content: [textContent('Look up verification.')] })]; + +const requests = { + openaiChat: buildChatCompletionsBody( + llmProfileSchema.parse({ profileId: 'chat', providerId: 'openai', model: 'gpt-4.1' }), + messages, + [tool], + ), + openaiResponses: buildOpenAIResponsesBody( + llmProfileSchema.parse({ + profileId: 'responses', + providerId: 'openai', + model: 'gpt-5-nano', + openAiApiMode: 'responses', + }), + messages, + [tool], + ), + anthropic: buildAnthropicMessagesBody( + llmProfileSchema.parse({ profileId: 'anthropic', providerId: 'anthropic', model: 'claude-sonnet-4-5' }), + messages, + [tool], + ), + gemini: buildGeminiInteractionsBody( + llmProfileSchema.parse({ profileId: 'gemini', providerId: 'gemini', model: 'gemini-3.5-flash-lite' }), + messages, + [tool], + ), +}; + +console.log(JSON.stringify( + Object.fromEntries(Object.entries(requests).map(([provider, request]) => [provider, request.tools])), + null, + 2, +)); diff --git a/src/llm/__tests__/gemini-client.test.ts b/src/llm/__tests__/gemini-client.test.ts index e04d9cf..354d5d8 100644 --- a/src/llm/__tests__/gemini-client.test.ts +++ b/src/llm/__tests__/gemini-client.test.ts @@ -167,6 +167,27 @@ describe('Gemini Interactions native tool calling', () => { ], }], [weatherTool])).toThrow(/Gemini function call 'call_bad'.*valid JSON object/u); }); + + it('rejects function results without a call id', () => { + expect(() => buildGeminiInteractionsBody(profile, [{ + role: 'tool', + content: [textContent('result')], + }], [weatherTool])).toThrow(/function result requires a tool_call_id/u); + }); + + it('rejects malformed known response steps', async () => { + const store = new InMemorySecretStore([[llmProviderSecretRef('gemini'), 'gemini-key']]); + const client = await createGeminiClientFromProfile(profile, store, { + fetch: fakeGeminiFetch({ + id: 'interaction_bad', + status: 'requires_action', + steps: [{ type: 'function_call', id: 'call_bad', name: 'get_weather', arguments: 'not-an-object' }], + }), + }); + + await expect(client.complete([{ role: 'user', content: [textContent('Weather?')] }], [weatherTool])).rejects.toThrow(); + }); + }); interface FakeFetchCall { diff --git a/src/llm/__tests__/native-tools.test.ts b/src/llm/__tests__/native-tools.test.ts new file mode 100644 index 0000000..c89d753 --- /dev/null +++ b/src/llm/__tests__/native-tools.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { ToolDefinition } from '../../tool/index.js'; +import { buildAnthropicMessagesBody } from '../anthropic.js'; +import { buildGeminiInteractionsBody } from '../gemini.js'; +import { llmProfileSchema, messageSchema, textContent } from '../index.js'; +import { buildChatCompletionsBody, buildOpenAIResponsesBody } from '../openai.js'; + +const tool = new ToolDefinition({ + name: 'lookup_value', + description: 'Look up a value by key.', + inputSchema: z.object({ key: z.string() }).strict(), +}); +const messages = [messageSchema.parse({ role: 'user', content: [textContent('Look it up.')] })]; + +const profiles = { + chat: llmProfileSchema.parse({ profileId: 'chat', providerId: 'openai', model: 'gpt-4.1' }), + responses: llmProfileSchema.parse({ + profileId: 'responses', + providerId: 'openai', + model: 'gpt-5-nano', + openAiApiMode: 'responses', + }), + anthropic: llmProfileSchema.parse({ profileId: 'anthropic', providerId: 'anthropic', model: 'claude-sonnet-4-5' }), + gemini: llmProfileSchema.parse({ profileId: 'gemini', providerId: 'gemini', model: 'gemini-3.5-flash-lite' }), +}; + +describe('cross-provider native tool serialization', () => { + it('derives every provider declaration from the same ToolDefinition', () => { + const chat = buildChatCompletionsBody(profiles.chat, messages, [tool]); + const responses = buildOpenAIResponsesBody(profiles.responses, messages, [tool]); + const anthropic = buildAnthropicMessagesBody(profiles.anthropic, messages, [tool]); + const gemini = buildGeminiInteractionsBody(profiles.gemini, messages, [tool]); + + expect(chat.tools).toMatchObject([{ type: 'function', function: { name: tool.name } }]); + expect(responses.tools).toMatchObject([{ type: 'function', name: tool.name }]); + expect(anthropic.tools).toMatchObject([{ name: tool.name, input_schema: { type: 'object' } }]); + expect(gemini.tools).toMatchObject([{ type: 'function', name: tool.name, parameters: { type: 'object' } }]); + }); + + it('omits provider tool fields when no definitions are supplied', () => { + const bodies = [ + buildChatCompletionsBody(profiles.chat, messages), + buildOpenAIResponsesBody(profiles.responses, messages), + buildAnthropicMessagesBody(profiles.anthropic, messages), + buildGeminiInteractionsBody(profiles.gemini, messages), + ]; + + for (const body of bodies) { + expect(body).not.toHaveProperty('tools'); + } + }); +}); diff --git a/src/llm/gemini.ts b/src/llm/gemini.ts index be339a3..355dd32 100644 --- a/src/llm/gemini.ts +++ b/src/llm/gemini.ts @@ -151,9 +151,12 @@ function toGeminiInteractionSteps(message: Message): readonly Record = { type: 'function_result', - call_id: message.tool_call_id ?? '', + call_id: message.tool_call_id, result: toGeminiContent(message.content), }; if (message.name !== null) { @@ -314,7 +317,10 @@ const geminiFunctionCallStepSchema = z arguments: z.record(z.string(), z.unknown()), }) .passthrough(); -const geminiOtherStepSchema = z.object({ type: z.string() }).passthrough(); +const knownGeminiStepTypes = new Set(['model_output', 'thought', 'function_call']); +const geminiOtherStepSchema = z + .object({ type: z.string().refine((type) => !knownGeminiStepTypes.has(type)) }) + .passthrough(); const geminiStepSchema = z.union([ geminiModelOutputStepSchema, geminiThoughtStepSchema, From bdbe851dc12efe20bd4bc650ec594f6187f1000d Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sat, 1 Aug 2026 00:10:29 +0200 Subject: [PATCH 6/7] fix: harden native tool continuation Group parallel Anthropic results into one user turn, preserve every signed thinking block, and reject malformed replay metadata before sending invalid provider payloads. Expand Gemini stateless replay coverage and make its live smoke prove signed-thought continuity. Record the independent audit evidence in Beads and align the provider research notes with the implemented fail-fast and stateless behavior. Co-authored-by: smolpaws Co-authored-by: openhands --- .beads/issues.jsonl | 34 ++++----- docs/NATIVE_TOOLS_RESEARCH.md | 16 ++--- examples/native-gemini-tools.ts | 10 ++- src/llm/__tests__/anthropic-client.test.ts | 83 ++++++++++++++++++---- src/llm/__tests__/gemini-client.test.ts | 46 +++++++++--- src/llm/anthropic.ts | 77 +++++++++++++++----- 6 files changed, 199 insertions(+), 67 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7ece995..0885064 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,21 +2,21 @@ {"id":"openhands-agent-0sl","title":"Audit Python SDK test and example parity","description":"Compare relevant Python agent-sdk tests and examples against TypeScript transpile coverage, excluding deliberate divergences (ACP runtime, confirmation/security analyzer execution, old SecretRegistry runtime). Add focused parity tests/examples for implemented relevant surfaces.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T02:42:26.577102+02:00","updated_at":"2026-07-05T02:46:49.449825+02:00","closed_at":"2026-07-05T02:46:49.449825+02:00","labels":["examples","tests","transpile"]} {"id":"openhands-agent-11i","title":"Port live provider API scripts","description":"Port legacy live scripts for OpenAI Responses reasoning round-trip and Anthropic prompt-caching smoke into this package with env-key skips, provider-keyed InMemorySecretStore, safe artifacts, and provider-format coverage.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-06T04:21:27.907958+02:00","updated_at":"2026-07-06T04:24:29.447073+02:00","closed_at":"2026-07-06T04:24:29.447073+02:00","labels":["live-tests","llm","providers"]} {"id":"openhands-agent-1sj","title":"Port RemoteWorkspace against local Python agent-server","description":"Implement TypeScript RemoteWorkspace parity using a real local Python SDK agent-server for tests, not mocked HTTP. Read Python remote workspace sources first, add red integration tests, then implement green.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T06:45:20.798424+02:00","updated_at":"2026-06-26T06:57:40.459403+02:00","closed_at":"2026-06-26T06:57:40.459403+02:00","labels":["remote","transpile","workspace"]} -{"id":"openhands-agent-2ba","title":"P4 \u2014 LLM layer (thin abstraction, fat clients)","description":"LLM layer is PROFILE-FIRST. LLM is used ONLY via LLM profiles \u2014 there is no bare/standalone LLM entry point in the public API. No model fallback chains, no implicit default model, nothing \u2014 just profiles. Profiles resolve provider API keys through SecretRef/keyring, never embedded raw values. Key lookup is provider-driven, not model-family-driven: explicit per-profile override first when enabled and present, otherwise provider key by providerId (e.g. litellm_proxy uses llm-provider:litellm_proxy even when the model string looks like OpenAI/Anthropic/Gemini). LLMClient is a deliberately THIN interface; most logic lives inside each client (do NOT over-abstract). The 4 clients sit BEHIND profile resolution, never exposed bare; build one at a time end-to-end: OpenAI/OpenAI-compatible, Anthropic Messages, Gemini interactions, OpenAI Responses. In-repo live-test scripts under scripts/live/ (NOT CI) using a GitHub environment named 'llm'. Minimal shared interface extracted LAST. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P4 LLM layer implemented profile-first. Added thin shared LLMClient contract extracted after clients, plus four profile-resolved clients behind LLMProfile + SecretStore: OpenAI/OpenAI-compatible chat completions, Anthropic Messages, Gemini generateContent, and OpenAI Responses. API keys are resolved from OS-keyring-compatible SecretStore by providerId/profile override; no raw secrets are embedded in profiles/settings. Provider lookup remains providerId-driven, not model-family-driven (e.g. litellm_proxy key for openai-looking model strings). Added keyring-only scripts/live/llm-smoke.mjs and npm run live:llm for non-CI live checks. Verification: node --check scripts/live/llm-smoke.mjs, npm test, typecheck, lint, and build pass (89 tests). Commits: 48f6a5c, 2333ed0, 84b5ff8, 58a42e8, bbee181, 57f7b63.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.567032+02:00","updated_at":"2026-06-24T05:21:05.440351+02:00","closed_at":"2026-06-24T05:21:05.440351+02:00","dependencies":[{"issue_id":"openhands-agent-2ba","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.052607+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-2ba","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.503792+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-2ba","title":"P4 — LLM layer (thin abstraction, fat clients)","description":"LLM layer is PROFILE-FIRST. LLM is used ONLY via LLM profiles — there is no bare/standalone LLM entry point in the public API. No model fallback chains, no implicit default model, nothing — just profiles. Profiles resolve provider API keys through SecretRef/keyring, never embedded raw values. Key lookup is provider-driven, not model-family-driven: explicit per-profile override first when enabled and present, otherwise provider key by providerId (e.g. litellm_proxy uses llm-provider:litellm_proxy even when the model string looks like OpenAI/Anthropic/Gemini). LLMClient is a deliberately THIN interface; most logic lives inside each client (do NOT over-abstract). The 4 clients sit BEHIND profile resolution, never exposed bare; build one at a time end-to-end: OpenAI/OpenAI-compatible, Anthropic Messages, Gemini interactions, OpenAI Responses. In-repo live-test scripts under scripts/live/ (NOT CI) using a GitHub environment named 'llm'. Minimal shared interface extracted LAST. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P4 LLM layer implemented profile-first. Added thin shared LLMClient contract extracted after clients, plus four profile-resolved clients behind LLMProfile + SecretStore: OpenAI/OpenAI-compatible chat completions, Anthropic Messages, Gemini generateContent, and OpenAI Responses. API keys are resolved from OS-keyring-compatible SecretStore by providerId/profile override; no raw secrets are embedded in profiles/settings. Provider lookup remains providerId-driven, not model-family-driven (e.g. litellm_proxy key for openai-looking model strings). Added keyring-only scripts/live/llm-smoke.mjs and npm run live:llm for non-CI live checks. Verification: node --check scripts/live/llm-smoke.mjs, npm test, typecheck, lint, and build pass (89 tests). Commits: 48f6a5c, 2333ed0, 84b5ff8, 58a42e8, bbee181, 57f7b63.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.567032+02:00","updated_at":"2026-06-24T05:21:05.440351+02:00","closed_at":"2026-06-24T05:21:05.440351+02:00","dependencies":[{"issue_id":"openhands-agent-2ba","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.052607+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-2ba","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.503792+02:00","created_by":"daemon"}]} {"id":"openhands-agent-2fh","title":"Decide smolpaws conversationRuntime deviation seam","description":"Assess conversationRuntime deviations from sdk-swap map: SecretRegistry to SecretStore, dropped security/confirmation surfaces, and clearRawLlmFieldsWhenProfileSelected. Implement package-level helpers/tests where the transpile should own them; document conscious drop decisions in code/tests rather than silently reintroducing old surfaces.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T01:54:35.738815+02:00","updated_at":"2026-07-05T01:56:57.197665+02:00","closed_at":"2026-07-05T01:56:57.197665+02:00","labels":["interop","secrets","settings","smolpaws"]} -{"id":"openhands-agent-2rb","title":"P2 \u2014 Types & settings models","description":"Transpile settings models and profiles, including SecretRef and a keyring-backed SecretStore abstraction. Do NOT port Python's Cipher, local plaintext secret persistence, or docker/remote/agent-server encrypted-at-rest branching. Settings/profiles persist secret references only. Raw secret values live in OS keyring service 'openhands'. LLM API keys are provider-scoped by default using accounts like llm-provider:, with explicit per-profile override accounts like llm-profile::api-key for cases where the same provider needs a second credential (for example app/eval litellm_proxy profiles). Pure-data; validates the zod approach at scale. Serialization round-trip tests. Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: P2 in progress. Implemented keyring-backed secret references, raw-secret-free LLM profiles, secret-free AgentProfile schemas, and profile-first settings schemas. Secret work: SecretRef serializes only {service, account}; InMemorySecretStore for tests; MacOSKeychainSecretStore via macOS security CLI; provider/profile LLM refs and resolution semantics. LLM profile schema covers providerId/model/baseUrl/generation params/headers/useProfileKeyOverride and rejects raw apiKey persistence. Agent profiles cover OpenHands/ACP variants with schema_version/id/revision/mcp refs, discriminator defaults, cross-variant rejection, ACP provider validation, null-vs-empty MCP refs, and no raw secrets. Settings now include ConversationSettings (max_iterations + observability metadata/tags, no confirmation/security fields per project deviation) and AgentSettings variants that use llm_profile_ref instead of embedded LLM/api_key. Verification: npm test, typecheck, lint, and build pass (68 tests). Commits: fd63387, 98e8882, e2c5794, ab28680.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.45944+02:00","updated_at":"2026-06-24T04:21:16.096466+02:00","closed_at":"2026-06-24T04:21:16.096466+02:00","dependencies":[{"issue_id":"openhands-agent-2rb","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.902124+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-2rb","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.374182+02:00","created_by":"daemon"}]} -{"id":"openhands-agent-5sg","title":"P6 \u2014 Context, condenser, skills","description":"Transpile context-window management, condensation, agent context, and skill discovery/validation. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.675828+02:00","updated_at":"2026-06-24T06:16:10.286343+02:00","closed_at":"2026-06-24T06:16:10.286343+02:00","dependencies":[{"issue_id":"openhands-agent-5sg","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.158281+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-5sg","depends_on_id":"openhands-agent-w38","type":"blocks","created_at":"2026-06-24T01:10:17.71862+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-2rb","title":"P2 — Types \u0026 settings models","description":"Transpile settings models and profiles, including SecretRef and a keyring-backed SecretStore abstraction. Do NOT port Python's Cipher, local plaintext secret persistence, or docker/remote/agent-server encrypted-at-rest branching. Settings/profiles persist secret references only. Raw secret values live in OS keyring service 'openhands'. LLM API keys are provider-scoped by default using accounts like llm-provider:\u003cproviderId\u003e, with explicit per-profile override accounts like llm-profile:\u003cprofileId\u003e:api-key for cases where the same provider needs a second credential (for example app/eval litellm_proxy profiles). Pure-data; validates the zod approach at scale. Serialization round-trip tests. Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: P2 in progress. Implemented keyring-backed secret references, raw-secret-free LLM profiles, secret-free AgentProfile schemas, and profile-first settings schemas. Secret work: SecretRef serializes only {service, account}; InMemorySecretStore for tests; MacOSKeychainSecretStore via macOS security CLI; provider/profile LLM refs and resolution semantics. LLM profile schema covers providerId/model/baseUrl/generation params/headers/useProfileKeyOverride and rejects raw apiKey persistence. Agent profiles cover OpenHands/ACP variants with schema_version/id/revision/mcp refs, discriminator defaults, cross-variant rejection, ACP provider validation, null-vs-empty MCP refs, and no raw secrets. Settings now include ConversationSettings (max_iterations + observability metadata/tags, no confirmation/security fields per project deviation) and AgentSettings variants that use llm_profile_ref instead of embedded LLM/api_key. Verification: npm test, typecheck, lint, and build pass (68 tests). Commits: fd63387, 98e8882, e2c5794, ab28680.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.45944+02:00","updated_at":"2026-06-24T04:21:16.096466+02:00","closed_at":"2026-06-24T04:21:16.096466+02:00","dependencies":[{"issue_id":"openhands-agent-2rb","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.902124+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-2rb","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.374182+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-5sg","title":"P6 — Context, condenser, skills","description":"Transpile context-window management, condensation, agent context, and skill discovery/validation. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.675828+02:00","updated_at":"2026-06-24T06:16:10.286343+02:00","closed_at":"2026-06-24T06:16:10.286343+02:00","dependencies":[{"issue_id":"openhands-agent-5sg","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.158281+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-5sg","depends_on_id":"openhands-agent-w38","type":"blocks","created_at":"2026-06-24T01:10:17.71862+02:00","created_by":"daemon"}]} {"id":"openhands-agent-5up","title":"Fix OpenAI usage parsing and real LLM examples","description":"OpenAI chat completions now return usage detail fields that break strict usage parsing. Make provider usage parsing tolerant where appropriate, add regression coverage, and add a shared env-backed example profile helper so examples CI exercises real LLM profiles via OPENAI_API_KEY while local no-key runs skip gracefully.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-07-05T08:30:36.910185+02:00","updated_at":"2026-07-05T08:35:35.049693+02:00","closed_at":"2026-07-05T08:35:35.049693+02:00","labels":["ci","examples","llm"]} -{"id":"openhands-agent-6ay","title":"P9 \u2014 Packaging, examples, docs, release 0.1.0","description":"Finalize packaging (exports, files), write examples and docs, cut release 0.1.0. Parent: openhands-agent-jad.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-06-24T01:10:08.83522+02:00","updated_at":"2026-06-24T06:40:37.756711+02:00","closed_at":"2026-06-24T06:40:37.756711+02:00","dependencies":[{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.319541+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-5sg","type":"blocks","created_at":"2026-06-24T01:10:17.886769+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-er1","type":"blocks","created_at":"2026-06-24T01:10:17.940442+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-dno","type":"blocks","created_at":"2026-06-24T01:10:17.993948+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-6ay","title":"P9 — Packaging, examples, docs, release 0.1.0","description":"Finalize packaging (exports, files), write examples and docs, cut release 0.1.0. Parent: openhands-agent-jad.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-06-24T01:10:08.83522+02:00","updated_at":"2026-06-24T06:40:37.756711+02:00","closed_at":"2026-06-24T06:40:37.756711+02:00","dependencies":[{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.319541+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-5sg","type":"blocks","created_at":"2026-06-24T01:10:17.886769+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-er1","type":"blocks","created_at":"2026-06-24T01:10:17.940442+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-6ay","depends_on_id":"openhands-agent-dno","type":"blocks","created_at":"2026-06-24T01:10:17.993948+02:00","created_by":"daemon"}]} {"id":"openhands-agent-7c5","title":"Add provider-specific examples for Gemini and Anthropic","description":"examples/_shared/exampleProfile.ts currently routes through createLlmClientFromProfile, so examples exercise OpenAI-compatible chat only even though CI now provides GEMINI_API_KEY and ANTHROPIC_API_KEY. Add provider-specific examples or route examples by provider if Engel wants Gemini/Anthropic coverage beyond live:* smoke scripts.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-07-07T06:09:33.706758+02:00","updated_at":"2026-07-07T06:20:42.425504+02:00","closed_at":"2026-07-07T06:20:42.425504+02:00","labels":["examples","llm","providers"]} {"id":"openhands-agent-7l9","title":"Seal smolpaws rename and settings seam","description":"Confirm Workspace/AgentServerWorkspace/OpenHandsSettings rename surface against smolpaws and old SDK. Add any package-level compatibility exports/tests needed for LocalWorkspace/RemoteWorkspace instanceof guard and OpenHandsAgentSettings field parity.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T01:52:37.095045+02:00","updated_at":"2026-07-05T01:54:17.425134+02:00","closed_at":"2026-07-05T01:54:17.425134+02:00","labels":["interop","settings","smolpaws"]} -{"id":"openhands-agent-a13","title":"P3 \u2014 Tool abstraction + registry","description":"Transpile the tool module: base Tool, tool registry, JSON-schema generation via zod v4 z.toJSONSchema() (replaces pydantic model_json_schema()). Then one concrete tool end-to-end as a vertical slice. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P3 tool abstraction and registry implemented and verified. Added zod-backed ToolDefinition with input/output validation, executor dispatch, MCP tool export via z.toJSONSchema(), Responses function-tool export, ToolAnnotations schema, ToolSpec schema, registry instance/factory resolution, usable filtering, and clear unknown/no-executor errors. Added concrete built-in vertical slice with FinishTool and ThinkTool, safe annotations, zod action schemas, observation validation, root exports, and tests. Verification: npm test, typecheck, lint, and build pass (77 tests). Commits: c283751, a0916a2.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.513669+02:00","updated_at":"2026-06-24T04:59:04.414849+02:00","closed_at":"2026-06-24T04:59:04.414849+02:00","dependencies":[{"issue_id":"openhands-agent-a13","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.992812+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-a13","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.427688+02:00","created_by":"daemon"}]} -{"id":"openhands-agent-bbh","title":"Add non-blocking async FileStore lock API","description":"Follow-up from PR #2 inline review: FileStore.lock is synchronous for the current local EventLog parity slice and its contention wait blocks the Node.js event loop. Design and implement an async lock API, then migrate server/runtime paths that may experience lock contention to non-blocking retries/timers while preserving the synchronous local API where needed for compatibility.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-07-11T05:32:20.629245+02:00","updated_at":"2026-07-12T02:46:09.190932+02:00","labels":["async","event-log","follow-up","io"],"closed_at":"2026-07-12T02:46:09.190932+02:00","notes":"Completed: added FileStore.lockAsync() for LocalFileStore and InMemoryFileStore, EventLog.appendAsync()/appendMultipleAsync(), ConversationState.appendEventAsync(), LocalConversation.sendMessageAsync(), and migrated async response dispatch/run error persistence to async appends. Validation: typecheck, lint, tests, build, example typecheck, and example tests pass."} +{"id":"openhands-agent-a13","title":"P3 — Tool abstraction + registry","description":"Transpile the tool module: base Tool, tool registry, JSON-schema generation via zod v4 z.toJSONSchema() (replaces pydantic model_json_schema()). Then one concrete tool end-to-end as a vertical slice. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P3 tool abstraction and registry implemented and verified. Added zod-backed ToolDefinition with input/output validation, executor dispatch, MCP tool export via z.toJSONSchema(), Responses function-tool export, ToolAnnotations schema, ToolSpec schema, registry instance/factory resolution, usable filtering, and clear unknown/no-executor errors. Added concrete built-in vertical slice with FinishTool and ThinkTool, safe annotations, zod action schemas, observation validation, root exports, and tests. Verification: npm test, typecheck, lint, and build pass (77 tests). Commits: c283751, a0916a2.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.513669+02:00","updated_at":"2026-06-24T04:59:04.414849+02:00","closed_at":"2026-06-24T04:59:04.414849+02:00","dependencies":[{"issue_id":"openhands-agent-a13","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.992812+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-a13","depends_on_id":"openhands-agent-ygp","type":"blocks","created_at":"2026-06-24T01:10:17.427688+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-bbh","title":"Add non-blocking async FileStore lock API","description":"Follow-up from PR #2 inline review: FileStore.lock is synchronous for the current local EventLog parity slice and its contention wait blocks the Node.js event loop. Design and implement an async lock API, then migrate server/runtime paths that may experience lock contention to non-blocking retries/timers while preserving the synchronous local API where needed for compatibility.","notes":"Completed: added FileStore.lockAsync() for LocalFileStore and InMemoryFileStore, EventLog.appendAsync()/appendMultipleAsync(), ConversationState.appendEventAsync(), LocalConversation.sendMessageAsync(), and migrated async response dispatch/run error persistence to async appends. Validation: typecheck, lint, tests, build, example typecheck, and example tests pass.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-07-11T05:32:20.629245+02:00","updated_at":"2026-07-12T02:46:09.190932+02:00","closed_at":"2026-07-12T02:46:09.190932+02:00","labels":["async","event-log","follow-up","io"]} {"id":"openhands-agent-dcw","title":"Document architecture and release 0.2.0","description":"Update docs/ for current TypeScript SDK status, add architecture documentation for main components, bump package release to 0.2.0, add release notes, verify, commit, tag, and push.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T03:04:06.371637+02:00","updated_at":"2026-07-05T03:07:26.939045+02:00","closed_at":"2026-07-05T03:07:26.939045+02:00","labels":["architecture","docs","release"]} -{"id":"openhands-agent-dno","title":"P8 \u2014 Concrete tools (openhands-tools equivalent)","description":"Transpile the concrete tools: terminal, file editor, browser, grep/glob, task tracker, etc. May become a separate package @smolpaws/openhands-tools later. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.782723+02:00","updated_at":"2026-06-24T06:36:00.662039+02:00","closed_at":"2026-06-24T06:36:00.662039+02:00","dependencies":[{"issue_id":"openhands-agent-dno","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.263246+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-dno","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.831398+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-dno","title":"P8 — Concrete tools (openhands-tools equivalent)","description":"Transpile the concrete tools: terminal, file editor, browser, grep/glob, task tracker, etc. May become a separate package @smolpaws/openhands-tools later. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.782723+02:00","updated_at":"2026-06-24T06:36:00.662039+02:00","closed_at":"2026-06-24T06:36:00.662039+02:00","dependencies":[{"issue_id":"openhands-agent-dno","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.263246+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-dno","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.831398+02:00","created_by":"daemon"}]} {"id":"openhands-agent-eae","title":"Port LLM provider format quirks","description":"Port scoped provider API format quirks from the legacy TS SDK into the fresh LLM clients: GPT-5 temperature stripping, Anthropic extended thinking/temp/budget/signature/cache-control, Gemini thinkingConfig and thoughtSignature round-trip, and regression tests.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-06T04:02:29.262578+02:00","updated_at":"2026-07-06T04:08:53.081082+02:00","closed_at":"2026-07-06T04:08:53.081082+02:00","labels":["llm","parity","providers"]} -{"id":"openhands-agent-er1","title":"P7 \u2014 Surrounding subsystems: hooks, critic, subagent, git, mcp (no security/confirmation)","description":"Transpile the surrounding subsystems: hooks, critic, subagent/delegation, git integration, and MCP client. Do NOT port security analyzers, risk scoring, confirmation gates, Python Cipher, or Python's secret storage split. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.729954+02:00","updated_at":"2026-06-24T06:29:20.730137+02:00","closed_at":"2026-06-24T06:29:20.730137+02:00","dependencies":[{"issue_id":"openhands-agent-er1","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.209996+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-er1","depends_on_id":"openhands-agent-w38","type":"blocks","created_at":"2026-06-24T01:10:17.773948+02:00","created_by":"daemon"}]} -{"id":"openhands-agent-jad","title":"Plan: transpile Python OpenHands agent-sdk to idiomatic TypeScript","description":"# Transpile Plan \u2014 Python OpenHands agent-sdk \u2192 idiomatic TypeScript\n\n> Source of truth for the roadmap is the beads issue **`openhands-agent-jad`**.\n> This doc mirrors it in Markdown for easy reading. Keep them in sync.\n\n## Objective\n\nProduce `@smolpaws/openhands-agent`: a fresh, idiomatic TypeScript implementation of the\nOpenHands Python `agent-sdk` (local source: `~/repos/agent-sdk`, upstream\n`OpenHands/software-agent-sdk`). We transpile *anew* \u2014 we do **not** copy the outdated TS\nattempt in `oh-tab/packages/agent-sdk`. That older code is reference-only (tooling, tests).\n\n## Pinned upstream target\n\nPython `OpenHands/software-agent-sdk` main @\n**`966340979be26c2162e9ab8805557b715e1f1a78`** (2026-06-23). We transpile against exactly this\ncommit and catch up to newer upstream in deliberate batches, not by chasing HEAD.\n\n## Source scope (Python core `openhands-sdk/openhands/sdk`, ~59k LOC, 93 pydantic files)\n\n| Module | Files | ~LOC | Notes |\n|--------|-------|------|-------|\n| llm | 40 | 10364 | LiteLLM-backed; biggest + riskiest |\n| conversation | 29 | 8378 | Local + Remote conversation, state, event loop |\n| agent | 9 | 7685 | The agent loop / step logic |\n| context | 25 | 3301 | Condenser, skills context, agent context |\n| settings | 5 | 3127 | Settings models |\n| skills | 9 | 2586 | Skill discovery/validation |\n| workspace | 10 | 2528 | Local/Remote/Apple workspace |\n| tool | 11 | 2360 | Tool base + registry |\n| security | 15 | 2084 | Confirmation, risk, analyzer |\n| utils | 16 | 2004 | Shared helpers |\n| event | 19 | 1923 | Event model hierarchy |\n| hooks | 6 | 1669 | Lifecycle hooks |\n| plugin | 7 | 1471 | Plugin system |\n| critic | 12 | 1446 | Critic models |\n| git | 6 | 1355 | Git integration |\n| profiles | 5 | 1267 | LLM profiles |\n| subagent | 4 | 1011 | Delegation |\n| extensions | 8 | 988 | |\n| mcp | 6 | 750 | MCP client |\n| marketplace | 4 | 649 | |\n| observability | 3 | 447 | |\n| io | 5 | 431 | |\n| logger | 3 | 330 | |\n| testing | 2 | 339 | test helpers |\n| secret | ? | 155 | Python source reference only; TS uses OS keyring, not Python's plaintext/encrypted-at-rest split |\n\nPlus `openhands-tools` (~16k LOC): concrete tools (terminal, file editor, browser, etc.).\n`openhands-agent-server` is out of scope for now (possible later sibling package).\n\n## Workflow: tests first (red/green)\n\n**The first thing in every unit of work is tests.** We port the Python tests *and* the examples\nbefore (or alongside) the implementation, and drive each module red \u2192 green:\n\n1. Port the relevant Python tests to vitest (conceptually \u2014 adapt to TS idioms, don't copy).\n2. Port the relevant examples so they compile and run against the new API.\n3. Watch them fail (red).\n4. Implement until they pass (green).\n\nExamples and tests are first-class deliverables, not an afterthought \u2014 they define the public\nAPI shape and are the executable spec for each phase.\n\n## Principles\n\n1. **Idiomatic TS, not literal port.** Respect the architecture (event/conversation/agent\n separation, tool abstraction) but use TS idioms: discriminated unions over class hierarchies\n where natural, `readonly`, narrow types, no Python-isms.\n2. **Type enforcement is non-negotiable.** `strict` + `noUncheckedIndexedAccess` +\n `exactOptionalPropertyTypes` + `verbatimModuleSyntax`. `no-explicit-any` is an error.\n3. **Runtime validation = zod v4.** The pydantic equivalent. Pydantic `BaseModel` \u2192 zod schema +\n `z.infer` type. zod v4's native `z.toJSONSchema()` covers the spots Python uses\n `model_json_schema()` (tool/settings schemas) \u2014 no separate `zod-to-json-schema` dep.\n4. **No code copy.** Read Python for behavior, write TS fresh. Port tests conceptually too.\n5. **Tooling parity with oh-tab** unless justified: tsup (ESM+CJS), vitest, eslint\n type-checked, tsc strict, target ES2022.\n6. **Wire-protocol compatibility.** TS types must serialize to the same JSON the Python SDK and\n agent-server expect. Round-trip serialization tests are the correctness anchor.\n7. **Secret safety overrides source parity.** Settings and profiles may persist secret references,\n never raw secret values. Runtime secret values live in an OS keyring backend (macOS Keychain\n first) under the `openhands` service; encryption/cipher/plaintext-storage machinery from\n Python is not ported. LLM API keys are provider-scoped by default, with explicit per-profile\n overrides only when the same provider needs multiple credentials.\n\n## Decisions (resolved 2026-06-23 with Engel)\n\n1. **zod v4** (4.4.3). Native JSON Schema; drop `zod-to-json-schema`. Done.\n2. **Single package** for starters; split into npm workspaces later.\n3. **LLM: thin abstraction, fat clients.** `LLMClient` is a deliberately thin interface; most\n logic lives inside each client. **Do not over-abstract.** Four clients, each owning its API's\n correctness + performance (request building, streaming, prompt caching, error mapping):\n - OpenAI / OpenAI-compatible (chat completions)\n - Anthropic Messages\n - Gemini (new interactions API)\n - OpenAI Responses API\n\n The shared surface is *extracted from what clients actually share*, built last \u2014 not designed\n up front. Live-test scripts live in `scripts/live/` (NOT CI), keys from a GitHub environment\n named `llm`, run on demand to confirm each API still works.\n4. **Pin upstream** at `9663409` (above). Local `~/repos/agent-sdk` synced to it.\n5. **Secrets: OS keyring, not Python's storage split.** The Python SDK has environment-specific\n secret behavior (plaintext local paths plus encrypted-at-rest docker/remote/agent-server\n handling). We intentionally do not port that complexity. The TS package persists only secret\n references in settings/profiles and stores actual values in the OS keyring under service\n `openhands`. macOS Keychain is the first supported backend; add Windows Credential Manager or\n Linux Secret Service later only if the abstraction stays simple. Environment variables may be\n used as ephemeral import/input, but not as persistent storage. LLM key resolution follows the\n OpenHands-Tab profile behavior: provider key as the shared default, optional per-profile\n override when a particular profile needs a different credential for the same provider.\n\n## Intended deviations from the Python SDK\n\nWe transpile **anew**, and on purpose we do NOT reproduce everything. The rule on public API:\n\n> **Public APIs should be consistent with the Python SDK across the transpilation** \u2014\n> same shapes, same names (adapted to TS idioms) \u2014 **EXCEPT** for the deviations below.\n> And even there, clean code / clean APIs win over fidelity. Idiomatic, clean TS is more\n> important than matching Python signature-for-signature.\n\n1. **No security analyzers. None.** We do not port the risk/security analyzer machinery. Drop it\n entirely \u2014 no `SecurityAnalyzer`, no risk scoring, no analyzer hooks.\n2. **No confirmation mechanism. None.** No confirmation policy, no human-in-the-loop confirm\n gates, no approval step before an action runs. The agent acts; we don't gate it.\n **IMPORTANT \u2014 this is NOT the pending-actions queue.** We absolutely KEEP the multi-tool-use\n pending-action mechanics: when the LLM emits multiple tool calls in one response, those become\n a queue of `ActionEvent`s, executed (incl. in parallel via the `ParallelToolExecutor`\n equivalent), with the \"unmatched actions\" tracking (`get_unmatched_actions`) and cancellation\n support. That is core execution machinery and is required. Only the *confirmation gate* is\n dropped \u2014 not the action queue.\n3. **LLM is used ONLY via LLM profiles.** There is no bare/standalone `LLM` entry point in the\n public API. You configure and select a profile; the SDK resolves the client from the profile.\n **No model fallback chains, no implicit default model, nothing** \u2014 just profiles. (The 4 clients\n from decision 3 sit *behind* the profile resolution, never exposed bare.)\n4. **Secrets are keyring-backed references.** Do not port Python's `Cipher`, local plaintext\n secret persistence, or docker/remote/agent-server encrypted-at-rest branching. Persistent\n settings/profiles contain stable references such as `{ service: 'openhands', account }`; the raw\n value is written to and read from OS keyring at runtime, then redacted from logs/events.\n Provider keys use accounts like `llm-provider:` (for example `llm-provider:openai`\n or `llm-provider:litellm_proxy`). Per-profile overrides use accounts like\n `llm-profile::api-key`, and are only used when enabled/selected for that profile.\n\n### LLM key resolution\n\nLLM API key lookup is provider-driven, not model-family-driven. A profile whose provider is\n`litellm_proxy` must resolve a `litellm_proxy` key even if its model string looks like an OpenAI,\nAnthropic, or Gemini model. The default keyring account for a provider is:\n\n- service: `openhands`\n- account: `llm-provider:`\n\nProfiles may opt into a profile-scoped key only when the same provider needs distinct credentials\nor endpoints. This covers cases like an app LiteLLM proxy profile and an eval LiteLLM proxy profile\nthat both use provider `litellm_proxy` but need different proxy API keys. The profile override\naccount is:\n\n- service: `openhands`\n- account: `llm-profile::api-key`\n\nResolution for a profile:\n\n1. If the profile explicitly enables a profile key override and that key exists, use\n `llm-profile::api-key`.\n2. Otherwise use `llm-provider:`.\n3. If neither exists, fail with a clear error telling the caller to set the provider key or enable\n and set a profile override.\n\nConsequence for the roadmap:\n- The Python `security` module (~2084 LOC: confirmation + risk + analyzer) is **mostly dropped**.\n P7 no longer includes security analyzers or confirmation. If any non-security piece currently\n lives under `security/` and is genuinely needed elsewhere, it moves to its real home \u2014 but the\n analyzer/confirmation surface itself is gone.\n- The LLM public surface is **profile-first**: `LLMProfile` in, resolved client out. Bare `LLM`\n is not part of the public API.\n- Secret handling is its own small settings/profile concern, not a port of Python's cipher stack:\n implement `SecretRef`/`SecretStore` around OS keyring, then make provider profiles refer to\n secrets by reference.\n\n## Phased roadmap\n\nEach phase is a bead (see `bd list`). Dependencies chained so `bd ready` surfaces the next\nworkable phase.\n\n- **P1 \u2014 Foundations:** utils, logger, io, event model. Low-dependency leaves first; establishes\n the zod patterns and the event discriminated-union shape everything builds on.\n- **P2 \u2014 Types & settings:** settings models, profiles, `SecretRef`, and the keyring-backed\n `SecretStore` abstraction. Settings/profiles serialize references only, never raw values.\n Model provider-key and profile-override references explicitly. Validates the zod approach at\n scale. Serialization round-trip tests. (deps: P1)\n- **P3 \u2014 Tool abstraction + registry:** base Tool, schema gen via `z.toJSONSchema()`, then one\n concrete tool end-to-end. (deps: P1)\n- **P4 \u2014 LLM layer (profile-first):** profiles are the *only* public entry point. The four\n clients (one sub-bead each, done end-to-end) sit behind profile resolution \u2014 never exposed\n bare. Profiles resolve API keys through `SecretRef`/keyring, not embedded values: explicit\n profile override first when enabled, otherwise provider key by `providerId` (not by model\n family). No model fallback chains, no implicit default model. Plus the live-test harness +\n `llm` environment, then the minimal shared interface extracted last. (deps: P1)\n- **P5 \u2014 Conversation + agent loop:** LocalConversation, RemoteConversation, ConversationState,\n agent step loop, stuck detection. (deps: P3, P4)\n- **P6 \u2014 Context & condenser, skills:** context-window management, condensation, skill\n discovery/validation. (deps: P5)\n- **P7 \u2014 Surrounding subsystems:** hooks, critic, subagent, git, mcp. **No security analyzers\n and no confirmation mechanism** (see Intended deviations). (deps: P5)\n- **P8 \u2014 Concrete tools** (`openhands-tools` equivalent): terminal, file editor, browser,\n grep/glob, task tracker, etc. May become a separate package later. (deps: P3)\n- **P9 \u2014 Packaging, examples, docs, release 0.1.0.** (deps: P6, P7, P8)\n\n## Reference materials\n\n- Python source: `~/repos/agent-sdk/openhands-sdk/openhands/sdk` and `openhands-tools`\n- Old TS attempt (reference only, do not copy): `~/repos/oh-tab/packages/agent-sdk`\n- Wire protocol: agent-server API + `OpenHands/typescript-client`\n","notes":"Current decisions: zod v4; single package for starters; profile-first LLM with thin shared interface and fat provider clients; upstream pinned to 966340979be26c2162e9ab8805557b715e1f1a78; tests/examples first. Intended deviations from Python: no security analyzers, no confirmation gate, no bare public LLM, and no Python secret storage stack. Secret handling is OS keyring based under service 'openhands': persist SecretRef-style references in settings/profiles, keep raw values in keyring, resolve at use time, and do not port Python Cipher/plaintext local persistence/docker-remote-agent-server encrypted-at-rest branching. LLM keys are provider-scoped by default (llm-provider:) with explicit per-profile overrides (llm-profile::api-key) when the same provider needs distinct credentials, e.g. app/eval litellm_proxy profiles. P2 owns SecretRef/SecretStore; P4 consumes it for provider API keys.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T00:33:36.815512+02:00","updated_at":"2026-06-24T03:32:02.174431+02:00","closed_at":"2026-06-24T02:28:11.213775+02:00"} +{"id":"openhands-agent-er1","title":"P7 — Surrounding subsystems: hooks, critic, subagent, git, mcp (no security/confirmation)","description":"Transpile the surrounding subsystems: hooks, critic, subagent/delegation, git integration, and MCP client. Do NOT port security analyzers, risk scoring, confirmation gates, Python Cipher, or Python's secret storage split. Parent: openhands-agent-jad.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-24T01:10:08.729954+02:00","updated_at":"2026-06-24T06:29:20.730137+02:00","closed_at":"2026-06-24T06:29:20.730137+02:00","dependencies":[{"issue_id":"openhands-agent-er1","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.209996+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-er1","depends_on_id":"openhands-agent-w38","type":"blocks","created_at":"2026-06-24T01:10:17.773948+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-jad","title":"Plan: transpile Python OpenHands agent-sdk to idiomatic TypeScript","description":"# Transpile Plan — Python OpenHands agent-sdk → idiomatic TypeScript\n\n\u003e Source of truth for the roadmap is the beads issue **`openhands-agent-jad`**.\n\u003e This doc mirrors it in Markdown for easy reading. Keep them in sync.\n\n## Objective\n\nProduce `@smolpaws/openhands-agent`: a fresh, idiomatic TypeScript implementation of the\nOpenHands Python `agent-sdk` (local source: `~/repos/agent-sdk`, upstream\n`OpenHands/software-agent-sdk`). We transpile *anew* — we do **not** copy the outdated TS\nattempt in `oh-tab/packages/agent-sdk`. That older code is reference-only (tooling, tests).\n\n## Pinned upstream target\n\nPython `OpenHands/software-agent-sdk` main @\n**`966340979be26c2162e9ab8805557b715e1f1a78`** (2026-06-23). We transpile against exactly this\ncommit and catch up to newer upstream in deliberate batches, not by chasing HEAD.\n\n## Source scope (Python core `openhands-sdk/openhands/sdk`, ~59k LOC, 93 pydantic files)\n\n| Module | Files | ~LOC | Notes |\n|--------|-------|------|-------|\n| llm | 40 | 10364 | LiteLLM-backed; biggest + riskiest |\n| conversation | 29 | 8378 | Local + Remote conversation, state, event loop |\n| agent | 9 | 7685 | The agent loop / step logic |\n| context | 25 | 3301 | Condenser, skills context, agent context |\n| settings | 5 | 3127 | Settings models |\n| skills | 9 | 2586 | Skill discovery/validation |\n| workspace | 10 | 2528 | Local/Remote/Apple workspace |\n| tool | 11 | 2360 | Tool base + registry |\n| security | 15 | 2084 | Confirmation, risk, analyzer |\n| utils | 16 | 2004 | Shared helpers |\n| event | 19 | 1923 | Event model hierarchy |\n| hooks | 6 | 1669 | Lifecycle hooks |\n| plugin | 7 | 1471 | Plugin system |\n| critic | 12 | 1446 | Critic models |\n| git | 6 | 1355 | Git integration |\n| profiles | 5 | 1267 | LLM profiles |\n| subagent | 4 | 1011 | Delegation |\n| extensions | 8 | 988 | |\n| mcp | 6 | 750 | MCP client |\n| marketplace | 4 | 649 | |\n| observability | 3 | 447 | |\n| io | 5 | 431 | |\n| logger | 3 | 330 | |\n| testing | 2 | 339 | test helpers |\n| secret | ? | 155 | Python source reference only; TS uses OS keyring, not Python's plaintext/encrypted-at-rest split |\n\nPlus `openhands-tools` (~16k LOC): concrete tools (terminal, file editor, browser, etc.).\n`openhands-agent-server` is out of scope for now (possible later sibling package).\n\n## Workflow: tests first (red/green)\n\n**The first thing in every unit of work is tests.** We port the Python tests *and* the examples\nbefore (or alongside) the implementation, and drive each module red → green:\n\n1. Port the relevant Python tests to vitest (conceptually — adapt to TS idioms, don't copy).\n2. Port the relevant examples so they compile and run against the new API.\n3. Watch them fail (red).\n4. Implement until they pass (green).\n\nExamples and tests are first-class deliverables, not an afterthought — they define the public\nAPI shape and are the executable spec for each phase.\n\n## Principles\n\n1. **Idiomatic TS, not literal port.** Respect the architecture (event/conversation/agent\n separation, tool abstraction) but use TS idioms: discriminated unions over class hierarchies\n where natural, `readonly`, narrow types, no Python-isms.\n2. **Type enforcement is non-negotiable.** `strict` + `noUncheckedIndexedAccess` +\n `exactOptionalPropertyTypes` + `verbatimModuleSyntax`. `no-explicit-any` is an error.\n3. **Runtime validation = zod v4.** The pydantic equivalent. Pydantic `BaseModel` → zod schema +\n `z.infer` type. zod v4's native `z.toJSONSchema()` covers the spots Python uses\n `model_json_schema()` (tool/settings schemas) — no separate `zod-to-json-schema` dep.\n4. **No code copy.** Read Python for behavior, write TS fresh. Port tests conceptually too.\n5. **Tooling parity with oh-tab** unless justified: tsup (ESM+CJS), vitest, eslint\n type-checked, tsc strict, target ES2022.\n6. **Wire-protocol compatibility.** TS types must serialize to the same JSON the Python SDK and\n agent-server expect. Round-trip serialization tests are the correctness anchor.\n7. **Secret safety overrides source parity.** Settings and profiles may persist secret references,\n never raw secret values. Runtime secret values live in an OS keyring backend (macOS Keychain\n first) under the `openhands` service; encryption/cipher/plaintext-storage machinery from\n Python is not ported. LLM API keys are provider-scoped by default, with explicit per-profile\n overrides only when the same provider needs multiple credentials.\n\n## Decisions (resolved 2026-06-23 with Engel)\n\n1. **zod v4** (4.4.3). Native JSON Schema; drop `zod-to-json-schema`. Done.\n2. **Single package** for starters; split into npm workspaces later.\n3. **LLM: thin abstraction, fat clients.** `LLMClient` is a deliberately thin interface; most\n logic lives inside each client. **Do not over-abstract.** Four clients, each owning its API's\n correctness + performance (request building, streaming, prompt caching, error mapping):\n - OpenAI / OpenAI-compatible (chat completions)\n - Anthropic Messages\n - Gemini (new interactions API)\n - OpenAI Responses API\n\n The shared surface is *extracted from what clients actually share*, built last — not designed\n up front. Live-test scripts live in `scripts/live/` (NOT CI), keys from a GitHub environment\n named `llm`, run on demand to confirm each API still works.\n4. **Pin upstream** at `9663409` (above). Local `~/repos/agent-sdk` synced to it.\n5. **Secrets: OS keyring, not Python's storage split.** The Python SDK has environment-specific\n secret behavior (plaintext local paths plus encrypted-at-rest docker/remote/agent-server\n handling). We intentionally do not port that complexity. The TS package persists only secret\n references in settings/profiles and stores actual values in the OS keyring under service\n `openhands`. macOS Keychain is the first supported backend; add Windows Credential Manager or\n Linux Secret Service later only if the abstraction stays simple. Environment variables may be\n used as ephemeral import/input, but not as persistent storage. LLM key resolution follows the\n OpenHands-Tab profile behavior: provider key as the shared default, optional per-profile\n override when a particular profile needs a different credential for the same provider.\n\n## Intended deviations from the Python SDK\n\nWe transpile **anew**, and on purpose we do NOT reproduce everything. The rule on public API:\n\n\u003e **Public APIs should be consistent with the Python SDK across the transpilation** —\n\u003e same shapes, same names (adapted to TS idioms) — **EXCEPT** for the deviations below.\n\u003e And even there, clean code / clean APIs win over fidelity. Idiomatic, clean TS is more\n\u003e important than matching Python signature-for-signature.\n\n1. **No security analyzers. None.** We do not port the risk/security analyzer machinery. Drop it\n entirely — no `SecurityAnalyzer`, no risk scoring, no analyzer hooks.\n2. **No confirmation mechanism. None.** No confirmation policy, no human-in-the-loop confirm\n gates, no approval step before an action runs. The agent acts; we don't gate it.\n **IMPORTANT — this is NOT the pending-actions queue.** We absolutely KEEP the multi-tool-use\n pending-action mechanics: when the LLM emits multiple tool calls in one response, those become\n a queue of `ActionEvent`s, executed (incl. in parallel via the `ParallelToolExecutor`\n equivalent), with the \"unmatched actions\" tracking (`get_unmatched_actions`) and cancellation\n support. That is core execution machinery and is required. Only the *confirmation gate* is\n dropped — not the action queue.\n3. **LLM is used ONLY via LLM profiles.** There is no bare/standalone `LLM` entry point in the\n public API. You configure and select a profile; the SDK resolves the client from the profile.\n **No model fallback chains, no implicit default model, nothing** — just profiles. (The 4 clients\n from decision 3 sit *behind* the profile resolution, never exposed bare.)\n4. **Secrets are keyring-backed references.** Do not port Python's `Cipher`, local plaintext\n secret persistence, or docker/remote/agent-server encrypted-at-rest branching. Persistent\n settings/profiles contain stable references such as `{ service: 'openhands', account }`; the raw\n value is written to and read from OS keyring at runtime, then redacted from logs/events.\n Provider keys use accounts like `llm-provider:\u003cproviderId\u003e` (for example `llm-provider:openai`\n or `llm-provider:litellm_proxy`). Per-profile overrides use accounts like\n `llm-profile:\u003cprofileId\u003e:api-key`, and are only used when enabled/selected for that profile.\n\n### LLM key resolution\n\nLLM API key lookup is provider-driven, not model-family-driven. A profile whose provider is\n`litellm_proxy` must resolve a `litellm_proxy` key even if its model string looks like an OpenAI,\nAnthropic, or Gemini model. The default keyring account for a provider is:\n\n- service: `openhands`\n- account: `llm-provider:\u003cproviderId\u003e`\n\nProfiles may opt into a profile-scoped key only when the same provider needs distinct credentials\nor endpoints. This covers cases like an app LiteLLM proxy profile and an eval LiteLLM proxy profile\nthat both use provider `litellm_proxy` but need different proxy API keys. The profile override\naccount is:\n\n- service: `openhands`\n- account: `llm-profile:\u003cprofileId\u003e:api-key`\n\nResolution for a profile:\n\n1. If the profile explicitly enables a profile key override and that key exists, use\n `llm-profile:\u003cprofileId\u003e:api-key`.\n2. Otherwise use `llm-provider:\u003cproviderId\u003e`.\n3. If neither exists, fail with a clear error telling the caller to set the provider key or enable\n and set a profile override.\n\nConsequence for the roadmap:\n- The Python `security` module (~2084 LOC: confirmation + risk + analyzer) is **mostly dropped**.\n P7 no longer includes security analyzers or confirmation. If any non-security piece currently\n lives under `security/` and is genuinely needed elsewhere, it moves to its real home — but the\n analyzer/confirmation surface itself is gone.\n- The LLM public surface is **profile-first**: `LLMProfile` in, resolved client out. Bare `LLM`\n is not part of the public API.\n- Secret handling is its own small settings/profile concern, not a port of Python's cipher stack:\n implement `SecretRef`/`SecretStore` around OS keyring, then make provider profiles refer to\n secrets by reference.\n\n## Phased roadmap\n\nEach phase is a bead (see `bd list`). Dependencies chained so `bd ready` surfaces the next\nworkable phase.\n\n- **P1 — Foundations:** utils, logger, io, event model. Low-dependency leaves first; establishes\n the zod patterns and the event discriminated-union shape everything builds on.\n- **P2 — Types \u0026 settings:** settings models, profiles, `SecretRef`, and the keyring-backed\n `SecretStore` abstraction. Settings/profiles serialize references only, never raw values.\n Model provider-key and profile-override references explicitly. Validates the zod approach at\n scale. Serialization round-trip tests. (deps: P1)\n- **P3 — Tool abstraction + registry:** base Tool, schema gen via `z.toJSONSchema()`, then one\n concrete tool end-to-end. (deps: P1)\n- **P4 — LLM layer (profile-first):** profiles are the *only* public entry point. The four\n clients (one sub-bead each, done end-to-end) sit behind profile resolution — never exposed\n bare. Profiles resolve API keys through `SecretRef`/keyring, not embedded values: explicit\n profile override first when enabled, otherwise provider key by `providerId` (not by model\n family). No model fallback chains, no implicit default model. Plus the live-test harness +\n `llm` environment, then the minimal shared interface extracted last. (deps: P1)\n- **P5 — Conversation + agent loop:** LocalConversation, RemoteConversation, ConversationState,\n agent step loop, stuck detection. (deps: P3, P4)\n- **P6 — Context \u0026 condenser, skills:** context-window management, condensation, skill\n discovery/validation. (deps: P5)\n- **P7 — Surrounding subsystems:** hooks, critic, subagent, git, mcp. **No security analyzers\n and no confirmation mechanism** (see Intended deviations). (deps: P5)\n- **P8 — Concrete tools** (`openhands-tools` equivalent): terminal, file editor, browser,\n grep/glob, task tracker, etc. May become a separate package later. (deps: P3)\n- **P9 — Packaging, examples, docs, release 0.1.0.** (deps: P6, P7, P8)\n\n## Reference materials\n\n- Python source: `~/repos/agent-sdk/openhands-sdk/openhands/sdk` and `openhands-tools`\n- Old TS attempt (reference only, do not copy): `~/repos/oh-tab/packages/agent-sdk`\n- Wire protocol: agent-server API + `OpenHands/typescript-client`\n","notes":"Current decisions: zod v4; single package for starters; profile-first LLM with thin shared interface and fat provider clients; upstream pinned to 966340979be26c2162e9ab8805557b715e1f1a78; tests/examples first. Intended deviations from Python: no security analyzers, no confirmation gate, no bare public LLM, and no Python secret storage stack. Secret handling is OS keyring based under service 'openhands': persist SecretRef-style references in settings/profiles, keep raw values in keyring, resolve at use time, and do not port Python Cipher/plaintext local persistence/docker-remote-agent-server encrypted-at-rest branching. LLM keys are provider-scoped by default (llm-provider:\u003cproviderId\u003e) with explicit per-profile overrides (llm-profile:\u003cprofileId\u003e:api-key) when the same provider needs distinct credentials, e.g. app/eval litellm_proxy profiles. P2 owns SecretRef/SecretStore; P4 consumes it for provider API keys.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T00:33:36.815512+02:00","updated_at":"2026-06-24T03:32:02.174431+02:00","closed_at":"2026-06-24T02:28:11.213775+02:00"} {"id":"openhands-agent-kwc","title":"Seal smolpaws pure helper import seam","description":"Port and export isMessageEvent, isConversationStateUpdateEvent, and reduceTextContent for smolpaws SDK swap read-path parity. Ground in swap-surface page, current TS package, and Python/old SDK event/message shapes; prove with tests.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-05T01:48:56.943931+02:00","updated_at":"2026-07-05T01:52:14.51193+02:00","closed_at":"2026-07-05T01:52:14.51193+02:00","labels":["helpers","interop","smolpaws"]} {"id":"openhands-agent-kx8","title":"Close post-0.1.0 transpilation gaps","description":"Parent for remaining TRANSPILE_PLAN gaps after 0.1.0. Scope excludes marketplace and plugins unless explicitly re-added. Work tests-first where applicable and preserve plan exceptions.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-06-26T05:44:25.817803+02:00","updated_at":"2026-06-26T06:03:11.839594+02:00","closed_at":"2026-06-26T06:03:11.839594+02:00","labels":["epic","follow-up","transpile"]} {"id":"openhands-agent-kx8.1","title":"Remove confirmation residues from TS SDK","description":"Remove public and local confirmation-shaped APIs and metadata handling while preserving pending-action and multi-tool execution. Review RemoteConversation.rejectPendingActions and subagent permission_mode confirm values. No dedicated cleanup tests required; run existing checks.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:44:32.317572+02:00","updated_at":"2026-06-26T05:48:21.751211+02:00","closed_at":"2026-06-26T05:48:21.751211+02:00","labels":["cleanup","confirmation","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.1","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:44:32.318689+02:00","created_by":"daemon"}]} @@ -27,11 +27,11 @@ {"id":"openhands-agent-kx8.6","title":"Add no-op-safe TS observability wrapper","description":"Read Python observability/laminar.py and utils.py. Add idiomatic TS wrapper compatible with standard JS OpenTelemetry and, if practical, Laminar. It must be no-op when env vars are absent. Add tests first for env gating, no-op behavior, and action-name helpers.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:45:02.445219+02:00","updated_at":"2026-06-26T06:02:30.884898+02:00","closed_at":"2026-06-26T06:02:30.884898+02:00","labels":["observability","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.6","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:45:02.445781+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.6","depends_on_id":"openhands-agent-kx8.2","type":"blocks","created_at":"2026-06-26T05:45:02.446871+02:00","created_by":"daemon"}]} {"id":"openhands-agent-kx8.7","title":"Expand applicable Python tests and examples coverage","description":"Port applicable Python examples/tests after underlying gaps land: persistence, async send-message-while-running, condenser, remote conversation, workspace, extensions, observability, testing helpers, and wire restore. Examples workflow remains manual or test-examples label only.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:45:12.196265+02:00","updated_at":"2026-06-26T06:02:59.14236+02:00","closed_at":"2026-06-26T06:02:59.14236+02:00","labels":["examples","tests","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:45:12.196889+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.3","type":"blocks","created_at":"2026-06-26T05:45:12.198157+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.4","type":"blocks","created_at":"2026-06-26T05:45:12.198771+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.5","type":"blocks","created_at":"2026-06-26T05:45:12.199328+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.6","type":"blocks","created_at":"2026-06-26T05:45:12.199858+02:00","created_by":"daemon"}]} {"id":"openhands-agent-mvm","title":"Fix examples GitHub environment OPENAI_API_KEY","description":"Manual examples workflow on main at e301a19 reached the real OpenAI profile path, but GitHub Actions failed with OpenAI HTTP 401 invalid_api_key. Code/local live run succeeded with the injected local credential, so the GitHub examples environment secret likely needs updating.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-07-05T08:36:48.625798+02:00","updated_at":"2026-07-06T05:32:50.995031+02:00","closed_at":"2026-07-06T05:32:50.995031+02:00","labels":["ci","examples","secrets"]} -{"id":"openhands-agent-tools-anthropic","title":"Implement Anthropic native tool calling","description":"Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.","notes":"Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T20:49:33.806344+02:00","labels":["anthropic","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T20:49:33.806344+02:00"} -{"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Completed 2026-07-30: Migrated GeminiClient from legacy generateContent to current /v1beta/interactions in stateless store:false mode. Added flat ToolDefinition serialization, signed thought/model/function step replay, function_result continuation, parallel function_call parsing, Interactions usage parsing, schema compatibility stripping, focused tests, and a credential-gated native tool example. Live gemini-3.5-flash-lite run dispatched lookup_value then finish successfully. Full suite: 254 tests pass; typecheck, lint, build, and example typecheck pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:14:13.467478+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:14:13.467478+02:00"} -{"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"Completed 2026-07-30: Non-OpenAI native tools now cover Anthropic Messages, Gemini Interactions, OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible routes. Provider beads and final validation are closed. Commits: research f710af3, Anthropic 09344cd, Gemini 41c9e46, compatible routes 29b2319, final validation pending this commit.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:24:13.322841+02:00","labels":["llm","tools","transpile"],"closed_at":"2026-07-31T21:24:13.322841+02:00"} -{"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Completed 2026-07-30: Confirmed the merged OpenAIChatClient path is the correct propagation implementation for OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible base URLs. Added route-level tests for endpoint/auth/tool payloads plus proxy tool-call and tool-result continuation round-trip. Documented that compatibility requires the standard Chat Completions function dialect and that native Anthropic/Gemini or nonstandard proxy dialect translation is not provided. Full suite: 257 tests pass; typecheck and lint pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:17:33.452730+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:17:33.452730+02:00"} -{"id":"openhands-agent-tools-research","title":"Research provider-native tool APIs and bounded oh-tab reference","description":"Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.","notes":"Completed 2026-07-30: Research document at docs/NATIVE_TOOLS_RESEARCH.md covers Anthropic (tools array, tool_use blocks, tool_result), Gemini Interactions (type:function, function_call steps, function_result, stateful mode), OpenAI-compatible (reuse Chat shape, provider gating). Captured oh-tab patterns. 4-phase plan: Anthropic \u2192 Gemini + migration \u2192 OpenAI-compatible \u2192 validation.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T17:01:34.378353+02:00","labels":["anthropic","gemini","llm","openai-compatible","research","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-research","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T17:01:34.378353+02:00"} -{"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Completed 2026-07-30: Added cross-provider ToolDefinition matrix tests, a keyless serialization example, provider architecture/status documentation, explicit compatible-proxy limits, and Gemini malformed replay guards. Live Gemini Interactions tool dispatch passed with gemini-3.5-flash-lite; Anthropic made no live calls. Verification: 40 files / 261 tests pass; typecheck, lint, build, example/live typechecks, keyless test:examples, and npm pack --dry-run pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:24:13.322841+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}],"closed_at":"2026-07-31T21:24:13.322841+02:00"} -{"id":"openhands-agent-w38","title":"P5 \u2014 Conversation + agent loop","description":"Conversation + agent loop. Transpile LocalConversation, RemoteConversation, ConversationState, the agent step loop, stuck detection. MUST include the multi-tool-use PENDING-ACTIONS QUEUE: when the LLM emits multiple tool calls, queue them as ActionEvents, execute (incl. parallel via ParallelToolExecutor equivalent), track unmatched actions (get_unmatched_actions), support cancellation/rejection of pending actions. This is core execution machinery (NOT confirmation) and is required. NO confirmation gate. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: Added RemoteConversation REST client slice. RemoteConversation now supports sendMessage without implicit run, run with optional blocking status polling, rejectPendingActions, pause, and interrupt over /api/conversations endpoints, with local executionStatus mirroring server terminal states. Verification after this slice: npm test, typecheck, lint, build pass (112 tests). P5 now covers ConversationState, LocalConversation, RemoteConversation, Agent step loop, stuck detection, pending/unmatched action queue, cancellation/rejection, and multi-tool parallel execution. Commits include 091c900, 38f112f, 56d9fa0, 9666ba6.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.619859+02:00","updated_at":"2026-06-24T05:57:53.318925+02:00","closed_at":"2026-06-24T05:57:53.318925+02:00","dependencies":[{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.105414+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.58951+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-2ba","type":"blocks","created_at":"2026-06-24T01:10:17.664653+02:00","created_by":"daemon"}]} -{"id":"openhands-agent-ygp","title":"P1 \u2014 Foundations: utils, logger, io, event model","description":"FOUNDATIONS \u2014 and the first real code, so it sets the workflow: TESTS + EXAMPLES FIRST (red/green). Port the relevant Python tests to vitest and the examples, watch them fail, then implement. Modules: utils, logger, io, event model (low-dependency leaves). Establishes the zod v4 patterns (pydantic BaseModel -> zod schema + z.infer) and the event discriminated-union shape everything builds on. Serialization round-trip tests against Python JSON fixtures. Public API stays consistent with Python (adapted to TS idioms); clean APIs win. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P1 foundations implemented and verified. Covered zod v4 LLM message/content schemas needed by events; Python-compatible event schemas and eventsToMessages batching/user-message coalescing; ACP tool call and hook execution event parity helpers; utils for async callback wrapping, truncate/path/github/paging/command/redaction/json/datetime/display/deprecated-field handling; LocalFileStore/InMemoryFileStore/MemoryLRUCache; lightweight neutral logger with no Python/LiteLLM-specific default suppression. Secret-handling decision recorded for P2: do NOT port Python Cipher/plaintext/encrypted-at-rest split; implement keyring-backed SecretRef/SecretStore instead. LLM keys are provider-scoped by default under keyring service 'openhands', with explicit per-profile overrides for cases like multiple litellm_proxy profiles using different proxy keys. Verification: npm test, typecheck, lint, and build pass (47 tests).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.396859+02:00","updated_at":"2026-06-24T04:03:13.724783+02:00","closed_at":"2026-06-24T04:03:13.724783+02:00","dependencies":[{"issue_id":"openhands-agent-ygp","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.796883+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-tools-anthropic","title":"Implement Anthropic native tool calling","description":"Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.","notes":"Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions. Audit fix 2026-08-01: reopened after finding that parallel tool results were not grouped into one user turn, only the first signed thinking block replayed, and malformed replay metadata failed open. Fixed all three; focused Anthropic suite has 14 passing tests; no Anthropic live calls.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["anthropic","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Completed 2026-07-30: Migrated GeminiClient from legacy generateContent to current /v1beta/interactions in stateless store:false mode. Added flat ToolDefinition serialization, signed thought/model/function step replay, function_result continuation, parallel function_call parsing, Interactions usage parsing, schema compatibility stripping, focused tests, and a credential-gated native tool example. Live gemini-3.5-flash-lite run dispatched lookup_value then finish successfully. Full suite: 254 tests pass; typecheck, lint, build, and example typecheck pass. Audit validation 2026-08-01: parallel function-result and multiple signed-thought replay are covered; malformed known steps fail while unknown future steps remain forward-compatible. Live gemini-3.5-flash-lite dispatched lookup_value then finish and observed 2 signed thought blocks.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"Completed 2026-07-30: Non-OpenAI native tools now cover Anthropic Messages, Gemini Interactions, OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible routes. Provider beads and final validation are closed. Commits: research f710af3, Anthropic 09344cd, Gemini 41c9e46, compatible routes 29b2319, final validation pending this commit. Independent audit completed 2026-08-01: provider serializers and complete main-branch diff reviewed; Anthropic continuation defects fixed; all provider and validation beads are complete.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["llm","tools","transpile"]} +{"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Completed 2026-07-30: Confirmed the merged OpenAIChatClient path is the correct propagation implementation for OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible base URLs. Added route-level tests for endpoint/auth/tool payloads plus proxy tool-call and tool-result continuation round-trip. Documented that compatibility requires the standard Chat Completions function dialect and that native Anthropic/Gemini or nonstandard proxy dialect translation is not provided. Full suite: 257 tests pass; typecheck and lint pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:17:33.45273+02:00","closed_at":"2026-07-31T21:17:33.45273+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-research","title":"Research provider-native tool APIs and bounded oh-tab reference","description":"Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.","notes":"Completed 2026-07-30: Research document at docs/NATIVE_TOOLS_RESEARCH.md covers Anthropic (tools array, tool_use blocks, tool_result), Gemini Interactions (type:function, function_call steps, function_result, stateful mode), OpenAI-compatible (reuse Chat shape, provider gating). Captured oh-tab patterns. 4-phase plan: Anthropic → Gemini + migration → OpenAI-compatible → validation.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T17:01:34.378353+02:00","closed_at":"2026-07-31T17:01:34.378353+02:00","labels":["anthropic","gemini","llm","openai-compatible","research","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-research","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Completed 2026-07-30: Added cross-provider ToolDefinition matrix tests, a keyless serialization example, provider architecture/status documentation, explicit compatible-proxy limits, and Gemini malformed replay guards. Live Gemini Interactions tool dispatch passed with gemini-3.5-flash-lite; Anthropic made no live calls. Verification: 40 files / 261 tests pass; typecheck, lint, build, example/live typechecks, keyless test:examples, and npm pack --dry-run pass. Independent audit gates 2026-08-01: 40 files / 267 tests, typecheck, lint, build, example/live typechecks, keyless examples, package dry-run, and Gemini live signed-thought/tool continuation all pass. Anthropic remained non-live.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-w38","title":"P5 — Conversation + agent loop","description":"Conversation + agent loop. Transpile LocalConversation, RemoteConversation, ConversationState, the agent step loop, stuck detection. MUST include the multi-tool-use PENDING-ACTIONS QUEUE: when the LLM emits multiple tool calls, queue them as ActionEvents, execute (incl. parallel via ParallelToolExecutor equivalent), track unmatched actions (get_unmatched_actions), support cancellation/rejection of pending actions. This is core execution machinery (NOT confirmation) and is required. NO confirmation gate. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: Added RemoteConversation REST client slice. RemoteConversation now supports sendMessage without implicit run, run with optional blocking status polling, rejectPendingActions, pause, and interrupt over /api/conversations endpoints, with local executionStatus mirroring server terminal states. Verification after this slice: npm test, typecheck, lint, build pass (112 tests). P5 now covers ConversationState, LocalConversation, RemoteConversation, Agent step loop, stuck detection, pending/unmatched action queue, cancellation/rejection, and multi-tool parallel execution. Commits include 091c900, 38f112f, 56d9fa0, 9666ba6.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.619859+02:00","updated_at":"2026-06-24T05:57:53.318925+02:00","closed_at":"2026-06-24T05:57:53.318925+02:00","dependencies":[{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.105414+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.58951+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-2ba","type":"blocks","created_at":"2026-06-24T01:10:17.664653+02:00","created_by":"daemon"}]} +{"id":"openhands-agent-ygp","title":"P1 — Foundations: utils, logger, io, event model","description":"FOUNDATIONS — and the first real code, so it sets the workflow: TESTS + EXAMPLES FIRST (red/green). Port the relevant Python tests to vitest and the examples, watch them fail, then implement. Modules: utils, logger, io, event model (low-dependency leaves). Establishes the zod v4 patterns (pydantic BaseModel -\u003e zod schema + z.infer) and the event discriminated-union shape everything builds on. Serialization round-trip tests against Python JSON fixtures. Public API stays consistent with Python (adapted to TS idioms); clean APIs win. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P1 foundations implemented and verified. Covered zod v4 LLM message/content schemas needed by events; Python-compatible event schemas and eventsToMessages batching/user-message coalescing; ACP tool call and hook execution event parity helpers; utils for async callback wrapping, truncate/path/github/paging/command/redaction/json/datetime/display/deprecated-field handling; LocalFileStore/InMemoryFileStore/MemoryLRUCache; lightweight neutral logger with no Python/LiteLLM-specific default suppression. Secret-handling decision recorded for P2: do NOT port Python Cipher/plaintext/encrypted-at-rest split; implement keyring-backed SecretRef/SecretStore instead. LLM keys are provider-scoped by default under keyring service 'openhands', with explicit per-profile overrides for cases like multiple litellm_proxy profiles using different proxy keys. Verification: npm test, typecheck, lint, and build pass (47 tests).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.396859+02:00","updated_at":"2026-06-24T04:03:13.724783+02:00","closed_at":"2026-06-24T04:03:13.724783+02:00","dependencies":[{"issue_id":"openhands-agent-ygp","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.796883+02:00","created_by":"daemon"}]} diff --git a/docs/NATIVE_TOOLS_RESEARCH.md b/docs/NATIVE_TOOLS_RESEARCH.md index 37497e1..e388e04 100644 --- a/docs/NATIVE_TOOLS_RESEARCH.md +++ b/docs/NATIVE_TOOLS_RESEARCH.md @@ -322,9 +322,9 @@ Tool results in continuation: - **NO LIVE TEST** per user instructions (Anthropic key has no billing) 6. **Edge cases from oh-tab** - - Tool arguments parsing: try JSON parse, fall back to raw if invalid - - Empty/missing descriptions: handle gracefully - - Tool choice: support `auto`, `any`, `none`, and specific tool selection + - The old client fell back to raw invalid arguments; the final client rejects them because Anthropic requires `tool_use.input` to be an object + - Consecutive parallel results are grouped into one user turn with multiple `tool_result` blocks + - The shared LLM contract currently exposes automatic choice only; provider-specific forced choice modes remain out of scope ### Phase 2: Gemini Interactions API native tools @@ -354,7 +354,7 @@ Tool results in continuation: } ``` - Add `generation_config.tool_choice: 'auto'` when tools are present - - Preserve `thinkingConfig.thinkingLevel` for Gemini 3.x + - Preserve lower-case `generation_config.thinking_level` and signed `thought` steps for Gemini 3.x 4. **Response parsing** - Parse `steps` array for `function_call` steps @@ -365,17 +365,17 @@ Tool results in continuation: - Collect `model_output` steps → message content 5. **Continuation serialization** - - Use `previous_interaction_id` for stateful conversations + - Use `store: false` and replay the complete durable transcript as typed steps; do not keep mutable `previous_interaction_id` state in the client - Convert `tool` role messages → `function_result` input items: ```typescript { type: 'function_result', - name: message.name ?? 'unknown_tool', - call_id: message.tool_call_id ?? '', + ...(message.name === null ? {} : { name: message.name }), + call_id: requireToolCallId(message), result: [{ type: 'text', text: contentToString(message.content).join('\n') }], } ``` - - Re-send tools in continuation request + - Re-send tools in continuation requests 6. **Tests** - Unit test: Interactions API request format with tools diff --git a/examples/native-gemini-tools.ts b/examples/native-gemini-tools.ts index 746a93c..0e554fb 100644 --- a/examples/native-gemini-tools.ts +++ b/examples/native-gemini-tools.ts @@ -45,18 +45,22 @@ if (store === null) { conversation.sendMessage('Look up the verification value and finish with it.'); await conversation.run(); - const actionNames = conversation.state.events - .filter((event) => event.kind === 'ActionEvent') - .map((event) => event.tool_name); + const actionEvents = conversation.state.events.filter((event) => event.kind === 'ActionEvent'); + const actionNames = actionEvents.map((event) => event.tool_name); + const signedThoughtCount = actionEvents.flatMap((event) => event.thinking_blocks) + .filter((block) => block.type === 'thinking' && block.signature !== null) + .length; assert(conversation.state.executionStatus === conversationExecutionStatus.FINISHED, `conversation status was ${conversation.state.executionStatus}`); assert(calls.includes('lookup_value'), 'lookup_value executor was not invoked'); assert(actionNames.includes('finish'), 'finish was not invoked as a native tool'); + assert(signedThoughtCount > 0, 'Gemini returned no signed thought step to replay'); console.log(JSON.stringify({ example: 'native-gemini-tools', model: profile.model, execution_status: conversation.state.executionStatus, native_action_tools: actionNames, + signed_thought_blocks: signedThoughtCount, })); } diff --git a/src/llm/__tests__/anthropic-client.test.ts b/src/llm/__tests__/anthropic-client.test.ts index 882e0f2..7d3d172 100644 --- a/src/llm/__tests__/anthropic-client.test.ts +++ b/src/llm/__tests__/anthropic-client.test.ts @@ -232,24 +232,86 @@ describe('Anthropic native tool calling', () => { }); }); - it('handles invalid tool arguments gracefully', () => { + it('groups parallel tool results into one Anthropic user turn', () => { const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); const messages = [ { role: 'assistant' as const, content: [], - tool_calls: [{ id: 'toolu_01A', responses_item_id: null, name: 'get_weather', arguments: 'not valid json', origin: 'completion' as const }], + tool_calls: [ + { id: 'toolu_01A', responses_item_id: null, name: 'get_weather', arguments: '{"location":"NYC"}', origin: 'completion' as const }, + { id: 'toolu_01B', responses_item_id: null, name: 'get_weather', arguments: '{"location":"Paris"}', origin: 'completion' as const }, + ], }, + { role: 'tool' as const, tool_call_id: 'toolu_01A', name: 'get_weather', content: [textContent('rain')] }, + { role: 'tool' as const, tool_call_id: 'toolu_01B', name: 'get_weather', content: [textContent('sun')] }, ]; const body = buildAnthropicMessagesBody(profile, messages, [testTool]); - expect(body.messages[0]?.content).toContainEqual({ - type: 'tool_use', - id: 'toolu_01A', - name: 'get_weather', - input: 'not valid json', + expect(body.messages).toEqual([ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_01A', name: 'get_weather', input: { location: 'NYC' } }, + { type: 'tool_use', id: 'toolu_01B', name: 'get_weather', input: { location: 'Paris' } }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_01A', content: 'rain' }, + { type: 'tool_result', tool_use_id: 'toolu_01B', content: 'sun' }, + ], + }, + ]); + }); + + it('replays every signed thinking block before tool calls', () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const body = buildAnthropicMessagesBody(profile, [{ + role: 'assistant', + content: [], + thinking_blocks: [ + { type: 'thinking', thinking: 'first', signature: 'sig_1' }, + { type: 'thinking', thinking: 'second', signature: 'sig_2' }, + ], + tool_calls: [{ id: 'toolu_01A', responses_item_id: null, name: 'get_weather', arguments: '{"location":"SF"}', origin: 'completion' }], + }], [testTool]); + + expect(body.messages).toEqual([{ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'first', signature: 'sig_1' }, + { type: 'thinking', thinking: 'second', signature: 'sig_2' }, + { type: 'tool_use', id: 'toolu_01A', name: 'get_weather', input: { location: 'SF' } }, + ], + }]); + }); + + it('rejects invalid replay arguments and missing result ids', () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + + expect(() => buildAnthropicMessagesBody(profile, [{ + role: 'assistant', + content: [], + tool_calls: [{ id: 'toolu_01A', responses_item_id: null, name: 'get_weather', arguments: 'not valid json', origin: 'completion' }], + }], [testTool])).toThrow(/Anthropic tool call 'toolu_01A'.*valid JSON object/u); + + expect(() => buildAnthropicMessagesBody(profile, [{ + role: 'tool', + content: [textContent('result')], + }], [testTool])).toThrow(/tool result requires a tool_call_id/u); + }); + + it('rejects malformed known response blocks', async () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const store = new InMemorySecretStore([[llmProviderSecretRef('anthropic'), 'anthropic-key']]); + const client = await createAnthropicClientFromProfile(profile, store, { + fetch: fakeAnthropicFetch({ content: [{ type: 'tool_use', name: 'get_weather', input: { location: 'SF' } }] }), }); + + await expect(client.complete([{ role: 'user', content: [textContent('Weather?')] }], [testTool])).rejects.toThrow(); }); }); @@ -259,12 +321,7 @@ interface FakeFetchCall { readonly body: Record; } -type AnthropicContentBlock = - | { type: 'text'; text: string } - | { type: 'tool_use'; id: string; name: string; input: unknown } - | { type: 'thinking'; thinking: string; signature?: string }; - -function fakeAnthropicFetch(response: { text: string } | { content: readonly AnthropicContentBlock[] }, calls: FakeFetchCall[] = []) { +function fakeAnthropicFetch(response: { text: string } | { content: readonly unknown[] }, calls: FakeFetchCall[] = []) { return async (url: string, init: { headers: Readonly>; body: string }) => { calls.push({ url, diff --git a/src/llm/__tests__/gemini-client.test.ts b/src/llm/__tests__/gemini-client.test.ts index 354d5d8..e0a7bf5 100644 --- a/src/llm/__tests__/gemini-client.test.ts +++ b/src/llm/__tests__/gemini-client.test.ts @@ -135,26 +135,39 @@ describe('Gemini Interactions native tool calling', () => { { role: 'assistant', content: [textContent('I will check.')], - reasoning_content: 'Use the weather tool.', - thinking_blocks: [{ type: 'thinking', thinking: 'Use the weather tool.', signature: 'thought_sig_123' }], + reasoning_content: 'Use the weather tool.Check both calls.', + thinking_blocks: [ + { type: 'thinking', thinking: 'Use the weather tool.', signature: 'thought_sig_123' }, + { type: 'thinking', thinking: 'Check both calls.', signature: 'thought_sig_456' }, + ], tool_calls: [ { id: 'call_1', responses_item_id: null, name: 'get_weather', arguments: '{"location":"Boston"}', origin: 'completion' }, + { id: 'call_2', responses_item_id: null, name: 'get_weather', arguments: '{"location":"Paris"}', origin: 'completion' }, ], }, { role: 'tool', tool_call_id: 'call_1', name: 'get_weather', content: [textContent('{"weather":"rain"}')] }, + { role: 'tool', tool_call_id: 'call_2', name: 'get_weather', content: [textContent('{"weather":"sun"}')] }, ], [weatherTool]); expect(body.input).toEqual([ { type: 'user_input', content: [{ type: 'text', text: 'Weather in Boston?' }] }, { type: 'thought', signature: 'thought_sig_123', summary: [{ type: 'text', text: 'Use the weather tool.' }] }, + { type: 'thought', signature: 'thought_sig_456', summary: [{ type: 'text', text: 'Check both calls.' }] }, { type: 'model_output', content: [{ type: 'text', text: 'I will check.' }] }, { type: 'function_call', id: 'call_1', name: 'get_weather', arguments: { location: 'Boston' } }, + { type: 'function_call', id: 'call_2', name: 'get_weather', arguments: { location: 'Paris' } }, { type: 'function_result', call_id: 'call_1', name: 'get_weather', result: [{ type: 'text', text: '{"weather":"rain"}' }], }, + { + type: 'function_result', + call_id: 'call_2', + name: 'get_weather', + result: [{ type: 'text', text: '{"weather":"sun"}' }], + }, ]); }); @@ -175,19 +188,36 @@ describe('Gemini Interactions native tool calling', () => { }], [weatherTool])).toThrow(/function result requires a tool_call_id/u); }); - it('rejects malformed known response steps', async () => { + it.each([ + [{ type: 'function_call', id: 'call_bad', name: 'get_weather', arguments: 'not-an-object' }], + [{ type: 'model_output', content: [{ type: 'text' }] }], + [{ type: 'thought', signature: 'sig_bad', summary: [{ type: 'text', text: 42 }] }], + ])('rejects malformed known response steps', async (steps) => { const store = new InMemorySecretStore([[llmProviderSecretRef('gemini'), 'gemini-key']]); const client = await createGeminiClientFromProfile(profile, store, { - fetch: fakeGeminiFetch({ - id: 'interaction_bad', - status: 'requires_action', - steps: [{ type: 'function_call', id: 'call_bad', name: 'get_weather', arguments: 'not-an-object' }], - }), + fetch: fakeGeminiFetch({ id: 'interaction_bad', status: 'requires_action', steps }), }); await expect(client.complete([{ role: 'user', content: [textContent('Weather?')] }], [weatherTool])).rejects.toThrow(); }); + it('ignores unknown future response steps without hiding known output', async () => { + const store = new InMemorySecretStore([[llmProviderSecretRef('gemini'), 'gemini-key']]); + const client = await createGeminiClientFromProfile(profile, store, { + fetch: fakeGeminiFetch({ + id: 'interaction_future', + status: 'completed', + steps: [ + { type: 'future_trace', payload: { value: 1 } }, + { type: 'model_output', content: [{ type: 'text', text: 'done' }] }, + ], + }), + }); + + const result = await client.complete([{ role: 'user', content: [textContent('Hello')] }], [weatherTool]); + + expect(result.message.content).toEqual([textContent('done')]); + }); }); interface FakeFetchCall { diff --git a/src/llm/anthropic.ts b/src/llm/anthropic.ts index 147b4c8..48e29cd 100644 --- a/src/llm/anthropic.ts +++ b/src/llm/anthropic.ts @@ -78,7 +78,10 @@ export function buildAnthropicMessagesBody(profile: LLMProfile, messages: readon const body: Record = { model: normalizedProfile.model, max_tokens: maxTokens, - messages: parsedMessages.filter((message) => message.role !== 'system').map((message) => toAnthropicMessage(normalizedProfile, message)), + messages: toAnthropicMessages( + normalizedProfile, + parsedMessages.filter((message) => message.role !== 'system'), + ), }; if (system.length > 0) { body.system = shouldCacheSystem @@ -113,6 +116,25 @@ function toAnthropicTool(tool: ToolDefinition): Record { }; } +function toAnthropicMessages(profile: LLMProfile, messages: readonly Message[]): readonly Record[] { + const result: Record[] = []; + for (const message of messages) { + if (message.role !== 'tool') { + result.push(toAnthropicMessage(profile, message)); + continue; + } + + const toolResult = toAnthropicToolResultBlock(message); + const previous = result.at(-1); + if (previous?.role === 'user' && Array.isArray(previous.content)) { + previous.content.push(toolResult); + } else { + result.push({ role: 'user', content: [toolResult] }); + } + } + return result; +} + function toAnthropicMessage(profile: LLMProfile, message: Message): Record { if (message.role === 'assistant') { return { role: 'assistant', content: toAnthropicAssistantContent(message) }; @@ -127,13 +149,12 @@ function toAnthropicMessage(profile: LLMProfile, message: Message): Record[] { - const blocks: Record[] = []; - const thinkingBlock = message.thinking_blocks.find( - (block): block is Extract => block.type === 'thinking' && block.signature !== null, - ); - if (thinkingBlock !== undefined) { - blocks.push({ type: 'thinking', thinking: thinkingBlock.thinking, signature: thinkingBlock.signature }); - } + const blocks: Record[] = message.thinking_blocks + .filter( + (block): block is Extract & { signature: string } => + block.type === 'thinking' && block.signature !== null, + ) + .map((block) => ({ type: 'thinking', thinking: block.thinking, signature: block.signature })); const text = reduceTextContent(message); if (text.length > 0) { @@ -150,17 +171,19 @@ function toAnthropicToolUseBlock(toolCall: MessageToolCall): Record { - const block: Record = { + if (message.tool_call_id === null) { + throw new Error('Anthropic tool result requires a tool_call_id.'); + } + return { type: 'tool_result', - tool_use_id: message.tool_call_id ?? '', + tool_use_id: message.tool_call_id, content: reduceTextContent(message), }; - return block; } function toAnthropicContentBlock(profile: LLMProfile, content: Content): Record { @@ -179,12 +202,17 @@ function toAnthropicContentBlock(profile: LLMProfile, content: Content): Record< return block; } -function parseToolArguments(args: string): unknown { +function parseToolArguments(toolCall: MessageToolCall): Record { + let parsed: unknown; try { - return JSON.parse(args) as unknown; + parsed = JSON.parse(toolCall.arguments) as unknown; } catch { - return args; + throw new Error(`Anthropic tool call '${toolCall.id}' arguments must be a valid JSON object.`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Anthropic tool call '${toolCall.id}' arguments must be a valid JSON object.`); } + return parsed as Record; } function parseAnthropicMessagesResponse(raw: unknown): LLMCompletionResponse { @@ -254,10 +282,23 @@ const anthropicThinkingBlockSchema = z .object({ type: z.literal('thinking'), thinking: z.string(), signature: z.string().nullable().optional() }) .passthrough(); const anthropicToolUseBlockSchema = z - .object({ type: z.literal('tool_use'), id: z.string(), name: z.string(), input: z.unknown() }) + .object({ + type: z.literal('tool_use'), + id: z.string(), + name: z.string(), + input: z.record(z.string(), z.unknown()), + }) + .passthrough(); +const knownAnthropicBlockTypes = new Set(['text', 'thinking', 'tool_use']); +const anthropicOtherBlockSchema = z + .object({ type: z.string().refine((type) => !knownAnthropicBlockTypes.has(type)) }) .passthrough(); -const anthropicOtherBlockSchema = z.object({ type: z.string() }).passthrough(); -const anthropicContentBlockSchema = z.union([anthropicTextBlockSchema, anthropicThinkingBlockSchema, anthropicToolUseBlockSchema, anthropicOtherBlockSchema]); +const anthropicContentBlockSchema = z.union([ + anthropicTextBlockSchema, + anthropicThinkingBlockSchema, + anthropicToolUseBlockSchema, + anthropicOtherBlockSchema, +]); type AnthropicTextBlock = z.infer; type AnthropicThinkingBlock = z.infer; From 8b832585391ba9f00e0e28448a33e6c887ec762d Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sat, 1 Aug 2026 13:37:56 +0200 Subject: [PATCH 7/7] fix: preserve provider reasoning replay Round-trip Anthropic redacted thinking blocks alongside signed thinking, and make Gemini reject malformed known text content without swallowing it through the forward-compatible fallback. Correct the malformed-content regression input and reconcile review findings in docs and Beads. Co-authored-by: smolpaws Co-authored-by: openhands --- .beads/issues.jsonl | 6 ++-- docs/REASONING_CAPABILITIES.md | 2 +- src/llm/__tests__/anthropic-client.test.ts | 25 +++++++++++++- src/llm/__tests__/gemini-client.test.ts | 10 +++--- src/llm/anthropic.ts | 39 ++++++++++++++-------- src/llm/gemini.ts | 4 ++- 6 files changed, 61 insertions(+), 25 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0885064..56c6b24 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -27,11 +27,11 @@ {"id":"openhands-agent-kx8.6","title":"Add no-op-safe TS observability wrapper","description":"Read Python observability/laminar.py and utils.py. Add idiomatic TS wrapper compatible with standard JS OpenTelemetry and, if practical, Laminar. It must be no-op when env vars are absent. Add tests first for env gating, no-op behavior, and action-name helpers.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:45:02.445219+02:00","updated_at":"2026-06-26T06:02:30.884898+02:00","closed_at":"2026-06-26T06:02:30.884898+02:00","labels":["observability","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.6","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:45:02.445781+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.6","depends_on_id":"openhands-agent-kx8.2","type":"blocks","created_at":"2026-06-26T05:45:02.446871+02:00","created_by":"daemon"}]} {"id":"openhands-agent-kx8.7","title":"Expand applicable Python tests and examples coverage","description":"Port applicable Python examples/tests after underlying gaps land: persistence, async send-message-while-running, condenser, remote conversation, workspace, extensions, observability, testing helpers, and wire restore. Examples workflow remains manual or test-examples label only.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-26T05:45:12.196265+02:00","updated_at":"2026-06-26T06:02:59.14236+02:00","closed_at":"2026-06-26T06:02:59.14236+02:00","labels":["examples","tests","transpile"],"dependencies":[{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8","type":"parent-child","created_at":"2026-06-26T05:45:12.196889+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.3","type":"blocks","created_at":"2026-06-26T05:45:12.198157+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.4","type":"blocks","created_at":"2026-06-26T05:45:12.198771+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.5","type":"blocks","created_at":"2026-06-26T05:45:12.199328+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-kx8.7","depends_on_id":"openhands-agent-kx8.6","type":"blocks","created_at":"2026-06-26T05:45:12.199858+02:00","created_by":"daemon"}]} {"id":"openhands-agent-mvm","title":"Fix examples GitHub environment OPENAI_API_KEY","description":"Manual examples workflow on main at e301a19 reached the real OpenAI profile path, but GitHub Actions failed with OpenAI HTTP 401 invalid_api_key. Code/local live run succeeded with the injected local credential, so the GitHub examples environment secret likely needs updating.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-07-05T08:36:48.625798+02:00","updated_at":"2026-07-06T05:32:50.995031+02:00","closed_at":"2026-07-06T05:32:50.995031+02:00","labels":["ci","examples","secrets"]} -{"id":"openhands-agent-tools-anthropic","title":"Implement Anthropic native tool calling","description":"Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.","notes":"Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions. Audit fix 2026-08-01: reopened after finding that parallel tool results were not grouped into one user turn, only the first signed thinking block replayed, and malformed replay metadata failed open. Fixed all three; focused Anthropic suite has 14 passing tests; no Anthropic live calls.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["anthropic","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-anthropic","title":"Implement Anthropic native tool calling","description":"Wire ToolDefinition[] through AnthropicClient.complete. Serialize tools to Anthropic native tool definitions, parse assistant tool_use blocks into MessageToolCall records, and serialize tool observations/results back into Anthropic messages on subsequent turns. Preserve existing text/reasoning behavior and keep the LLMClient tools parameter optional for compatibility.","notes":"Completed 2026-07-30: Added native tool calling to AnthropicMessagesClient. Tools parameter added to complete(), buildAnthropicMessagesBody serializes tools to Anthropic format (name/description/input_schema), tool_use blocks parsed into MessageToolCall[], tool_result continuation already worked via existing toAnthropicMessage. All 11 tests pass (request serialization, no-tools omission, tool_use parsing, parallel calls, continuation, invalid args). Zero live calls per instructions. Audit fix 2026-08-01: reopened after finding that parallel tool results were not grouped into one user turn, only the first signed thinking block replayed, and malformed replay metadata failed open. Fixed all three; PR review then added redacted_thinking parse/replay coverage; focused Anthropic suite has 15 passing tests; no Anthropic live calls.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["anthropic","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-anthropic","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} {"id":"openhands-agent-tools-gemini","title":"Implement Gemini Interactions API native tool calling","description":"Wire ToolDefinition[] through GeminiClient.complete using the current Google Gemini Interactions API, not the old Gemini API. Serialize function/tool declarations, parse model function calls into MessageToolCall records, and serialize tool results back into Interactions-compatible input on later turns. Keep thought-signature/reasoning round-trip behavior intact.","notes":"Completed 2026-07-30: Migrated GeminiClient from legacy generateContent to current /v1beta/interactions in stateless store:false mode. Added flat ToolDefinition serialization, signed thought/model/function step replay, function_result continuation, parallel function_call parsing, Interactions usage parsing, schema compatibility stripping, focused tests, and a credential-gated native tool example. Live gemini-3.5-flash-lite run dispatched lookup_value then finish successfully. Full suite: 254 tests pass; typecheck, lint, build, and example typecheck pass. Audit validation 2026-08-01: parallel function-result and multiple signed-thought replay are covered; malformed known steps fail while unknown future steps remain forward-compatible. Live gemini-3.5-flash-lite dispatched lookup_value then finish and observed 2 signed thought blocks.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["gemini","interactions-api","llm","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-gemini","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} -{"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"Completed 2026-07-30: Non-OpenAI native tools now cover Anthropic Messages, Gemini Interactions, OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible routes. Provider beads and final validation are closed. Commits: research f710af3, Anthropic 09344cd, Gemini 41c9e46, compatible routes 29b2319, final validation pending this commit. Independent audit completed 2026-08-01: provider serializers and complete main-branch diff reviewed; Anthropic continuation defects fixed; all provider and validation beads are complete.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["llm","tools","transpile"]} +{"id":"openhands-agent-tools-native","title":"Provider-native tool calling for non-OpenAI clients","description":"Implement native tool-calling support after PR #7 merged the OpenAI path. Scope is Anthropic, Gemini via the current Interactions API, and OpenAI-compatible clients/proxies. Keep this bounded to the TypeScript SDK four-client architecture and do not port LiteLLM. Use the old oh-tab implementation only as a working reference, not as a source to transplant wholesale. Stay roughly aligned with the local Python agent-sdk flow where Agent passes resolved ToolDefinition instances to provider-specific LLM code.","notes":"Completed 2026-07-30: Non-OpenAI native tools now cover Anthropic Messages, Gemini Interactions, OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible routes. Provider beads and final validation are closed. Commits: research f710af3, Anthropic 09344cd, Gemini 41c9e46, compatible routes 29b2319, validation 168eb92. Independent audit completed 2026-08-01: provider serializers and complete main-branch diff reviewed; Anthropic continuation defects fixed; all provider and validation beads are complete.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["llm","tools","transpile"]} {"id":"openhands-agent-tools-openai-compatible","title":"Implement OpenAI-compatible client tool propagation and gating","description":"Decide and implement the OpenAI-compatible chat behavior for providerId/baseUrl routes such as OpenRouter, LiteLLM-compatible servers, and custom OpenAI-compatible proxies. Reuse the Chat Completions native tool shape where safe, add route/provider gating where provider dialects differ, and document unsupported cases. Do not add LiteLLM as a dependency or port Python LiteLLM abstractions.","notes":"Completed 2026-07-30: Confirmed the merged OpenAIChatClient path is the correct propagation implementation for OpenRouter, LiteLLM-compatible, and custom OpenAI-compatible base URLs. Added route-level tests for endpoint/auth/tool payloads plus proxy tool-call and tool-result continuation round-trip. Documented that compatibility requires the standard Chat Completions function dialect and that native Anthropic/Gemini or nonstandard proxy dialect translation is not provided. Full suite: 257 tests pass; typecheck and lint pass.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T21:17:33.45273+02:00","closed_at":"2026-07-31T21:17:33.45273+02:00","labels":["llm","openai-compatible","openrouter","tools","transpile"],"dependencies":[{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-openai-compatible","depends_on_id":"openhands-agent-tools-research","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} {"id":"openhands-agent-tools-research","title":"Research provider-native tool APIs and bounded oh-tab reference","description":"Read the latest Anthropic tool-use docs and the current Google Gemini Interactions API docs, not the older Gemini API shape. Also inspect the old oh-tab implementation only to identify proven data-shape choices and edge cases. Produce a concise implementation plan for Anthropic, Gemini Interactions, and OpenAI-compatible clients in this repo architecture.","notes":"Completed 2026-07-30: Research document at docs/NATIVE_TOOLS_RESEARCH.md covers Anthropic (tools array, tool_use blocks, tool_result), Gemini Interactions (type:function, function_call steps, function_result, stateful mode), OpenAI-compatible (reuse Chat shape, provider gating). Captured oh-tab patterns. 4-phase plan: Anthropic → Gemini + migration → OpenAI-compatible → validation.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-07-31T17:01:34.378353+02:00","closed_at":"2026-07-31T17:01:34.378353+02:00","labels":["anthropic","gemini","llm","openai-compatible","research","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-research","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} -{"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Completed 2026-07-30: Added cross-provider ToolDefinition matrix tests, a keyless serialization example, provider architecture/status documentation, explicit compatible-proxy limits, and Gemini malformed replay guards. Live Gemini Interactions tool dispatch passed with gemini-3.5-flash-lite; Anthropic made no live calls. Verification: 40 files / 261 tests pass; typecheck, lint, build, example/live typechecks, keyless test:examples, and npm pack --dry-run pass. Independent audit gates 2026-08-01: 40 files / 267 tests, typecheck, lint, build, example/live typechecks, keyless examples, package dry-run, and Gemini live signed-thought/tool continuation all pass. Anthropic remained non-live.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} +{"id":"openhands-agent-tools-validation","title":"Add cross-provider native-tool tests, examples, and docs","description":"After Anthropic, Gemini Interactions, and OpenAI-compatible tool support land, add cross-provider regression tests and documentation that describe the common ToolDefinition flow and each provider serializer. Keep live examples credential-gated and bounded; do not require live provider keys for normal CI.","notes":"Completed 2026-07-30: Added cross-provider ToolDefinition matrix tests, a keyless serialization example, provider architecture/status documentation, explicit compatible-proxy limits, and Gemini malformed replay guards. Live Gemini Interactions tool dispatch passed with gemini-3.5-flash-lite; Anthropic made no live calls. Verification: 40 files / 261 tests pass; typecheck, lint, build, example/live typechecks, keyless test:examples, and npm pack --dry-run pass. Independent audit gates 2026-08-01: 40 files / 267 tests passed initially; final post-review gate has 268 tests plus typecheck, lint, build, example/live typechecks, keyless examples, package dry-run, and Gemini live signed-thought/tool continuation all passing. Anthropic remained non-live.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-07-15T01:12:47.261769+02:00","updated_at":"2026-08-01T00:03:40+02:00","closed_at":"2026-08-01T00:03:40+02:00","labels":["docs","examples","llm","tests","tools"],"dependencies":[{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-native","type":"parent-child","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-anthropic","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-gemini","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"},{"issue_id":"openhands-agent-tools-validation","depends_on_id":"openhands-agent-tools-openai-compatible","type":"blocks","created_at":"2026-07-15T01:12:47.261769+02:00","created_by":"openhands"}]} {"id":"openhands-agent-w38","title":"P5 — Conversation + agent loop","description":"Conversation + agent loop. Transpile LocalConversation, RemoteConversation, ConversationState, the agent step loop, stuck detection. MUST include the multi-tool-use PENDING-ACTIONS QUEUE: when the LLM emits multiple tool calls, queue them as ActionEvents, execute (incl. parallel via ParallelToolExecutor equivalent), track unmatched actions (get_unmatched_actions), support cancellation/rejection of pending actions. This is core execution machinery (NOT confirmation) and is required. NO confirmation gate. Tests + examples first (red/green). Parent: openhands-agent-jad.","notes":"Progress 2026-06-24: Added RemoteConversation REST client slice. RemoteConversation now supports sendMessage without implicit run, run with optional blocking status polling, rejectPendingActions, pause, and interrupt over /api/conversations endpoints, with local executionStatus mirroring server terminal states. Verification after this slice: npm test, typecheck, lint, build pass (112 tests). P5 now covers ConversationState, LocalConversation, RemoteConversation, Agent step loop, stuck detection, pending/unmatched action queue, cancellation/rejection, and multi-tool parallel execution. Commits include 091c900, 38f112f, 56d9fa0, 9666ba6.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.619859+02:00","updated_at":"2026-06-24T05:57:53.318925+02:00","closed_at":"2026-06-24T05:57:53.318925+02:00","dependencies":[{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:17.105414+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-a13","type":"blocks","created_at":"2026-06-24T01:10:17.58951+02:00","created_by":"daemon"},{"issue_id":"openhands-agent-w38","depends_on_id":"openhands-agent-2ba","type":"blocks","created_at":"2026-06-24T01:10:17.664653+02:00","created_by":"daemon"}]} {"id":"openhands-agent-ygp","title":"P1 — Foundations: utils, logger, io, event model","description":"FOUNDATIONS — and the first real code, so it sets the workflow: TESTS + EXAMPLES FIRST (red/green). Port the relevant Python tests to vitest and the examples, watch them fail, then implement. Modules: utils, logger, io, event model (low-dependency leaves). Establishes the zod v4 patterns (pydantic BaseModel -\u003e zod schema + z.infer) and the event discriminated-union shape everything builds on. Serialization round-trip tests against Python JSON fixtures. Public API stays consistent with Python (adapted to TS idioms); clean APIs win. Parent: openhands-agent-jad.","notes":"Completed 2026-06-24: P1 foundations implemented and verified. Covered zod v4 LLM message/content schemas needed by events; Python-compatible event schemas and eventsToMessages batching/user-message coalescing; ACP tool call and hook execution event parity helpers; utils for async callback wrapping, truncate/path/github/paging/command/redaction/json/datetime/display/deprecated-field handling; LocalFileStore/InMemoryFileStore/MemoryLRUCache; lightweight neutral logger with no Python/LiteLLM-specific default suppression. Secret-handling decision recorded for P2: do NOT port Python Cipher/plaintext/encrypted-at-rest split; implement keyring-backed SecretRef/SecretStore instead. LLM keys are provider-scoped by default under keyring service 'openhands', with explicit per-profile overrides for cases like multiple litellm_proxy profiles using different proxy keys. Verification: npm test, typecheck, lint, and build pass (47 tests).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-06-24T01:10:08.396859+02:00","updated_at":"2026-06-24T04:03:13.724783+02:00","closed_at":"2026-06-24T04:03:13.724783+02:00","dependencies":[{"issue_id":"openhands-agent-ygp","depends_on_id":"openhands-agent-jad","type":"parent-child","created_at":"2026-06-24T01:10:16.796883+02:00","created_by":"daemon"}]} diff --git a/docs/REASONING_CAPABILITIES.md b/docs/REASONING_CAPABILITIES.md index 43bf662..84c0f92 100644 --- a/docs/REASONING_CAPABILITIES.md +++ b/docs/REASONING_CAPABILITIES.md @@ -210,7 +210,7 @@ Anthropic and LiteLLM credentials/base URLs were not available in this environme | Anthropic modern adaptive models | `output_config.effort` and `thinking: { type: "adaptive" }` where applicable | effort values include `low`, `medium`, `high`, `xhigh`, `max`, with model restrictions: `xhigh` only on Fable 5, Mythos 5, Opus 4.8, Opus 4.7, Sonnet 5 per docs; `max` availability differs by model | Current code converts legacy `reasoningEffort` to manual `budget_tokens`, which is wrong for Fable 5, Opus 4.8, Sonnet 5, and deprecated for Sonnet/Opus 4.6. | | Anthropic manual thinking models | `thinking: { type: "enabled", budget_tokens, display? }` | explicit token budget less than `max_tokens`; display values include `summarized`, `omitted` where supported | Current code invents budget from legacy effort. Replace with explicit budget config. | | Anthropic task budgets beta | `output_config.task_budget` plus beta header | `{ type: "tokens", total, remaining? }` and opt-in beta header | Useful for future agent loops; should not be folded into a simple effort enum. | -| Gemini Interactions | `generation_config.thinking_level`, `generation_config.thinking_summaries` | lower-case model-specific values such as `minimal`, `low`, `medium`, `high`; exact set depends on model | Implemented with the legacy profile's `low | medium | high` subset and automatic summaries; model-specific capability validation remains future work. | +| Gemini Interactions | `generation_config.thinking_level`, `generation_config.thinking_summaries` | lower-case model-specific values such as `minimal`, `low`, `medium`, `high`; exact set depends on model | Implemented with the legacy profile's `low`, `medium`, `high` subset and automatic summaries; model-specific capability validation remains future work. | | Gemini GenerateContent | `generationConfig.thinkingConfig` | mutually exclusive level or budget branches: level values `MINIMAL`, `LOW`, `MEDIUM`, `HIGH`, or a numeric `thinkingBudget`; `includeThoughts` may accompany either branch | No longer used by `GeminiClient`; retained here only as research evidence for the distinct legacy API. | | LiteLLM proxy / OpenAI-compatible transport | transport remains OpenAI-compatible, but upstream model family comes from proxy alias/prefix/config | Resolve capabilities from upstream namespace: `anthropic/...`, `gemini/...`, `openai/...`, known `claude`/`gemini`/`gpt` aliases, or explicit profile metadata | Current factory treats `litellm_proxy` as generic OpenAI-compatible Chat, so it cannot expose native Anthropic/Gemini semantics. | diff --git a/src/llm/__tests__/anthropic-client.test.ts b/src/llm/__tests__/anthropic-client.test.ts index 7d3d172..12add63 100644 --- a/src/llm/__tests__/anthropic-client.test.ts +++ b/src/llm/__tests__/anthropic-client.test.ts @@ -175,6 +175,27 @@ describe('Anthropic native tool calling', () => { ]); }); + it('preserves redacted thinking blocks from responses', async () => { + const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); + const store = new InMemorySecretStore([[llmProviderSecretRef('anthropic'), 'anthropic-key']]); + const client = await createAnthropicClientFromProfile(profile, store, { + fetch: fakeAnthropicFetch({ + content: [ + { type: 'thinking', thinking: 'first', signature: 'sig_1' }, + { type: 'redacted_thinking', data: 'encrypted_1' }, + { type: 'text', text: 'done' }, + ], + }), + }); + + const result = await client.complete([{ role: 'user', content: [textContent('Think')] }]); + + expect(result.message.thinking_blocks).toEqual([ + { type: 'thinking', thinking: 'first', signature: 'sig_1' }, + { type: 'redacted_thinking', data: 'encrypted_1' }, + ]); + }); + it('handles multiple parallel tool calls', async () => { const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); const store = new InMemorySecretStore([[llmProviderSecretRef('anthropic'), 'anthropic-key']]); @@ -267,13 +288,14 @@ describe('Anthropic native tool calling', () => { ]); }); - it('replays every signed thinking block before tool calls', () => { + it('replays every signed and redacted thinking block before tool calls', () => { const profile = llmProfileSchema.parse({ profileId: 'sonnet', providerId: 'anthropic', model: 'claude-sonnet-4-5' }); const body = buildAnthropicMessagesBody(profile, [{ role: 'assistant', content: [], thinking_blocks: [ { type: 'thinking', thinking: 'first', signature: 'sig_1' }, + { type: 'redacted_thinking', data: 'encrypted_1' }, { type: 'thinking', thinking: 'second', signature: 'sig_2' }, ], tool_calls: [{ id: 'toolu_01A', responses_item_id: null, name: 'get_weather', arguments: '{"location":"SF"}', origin: 'completion' }], @@ -283,6 +305,7 @@ describe('Anthropic native tool calling', () => { role: 'assistant', content: [ { type: 'thinking', thinking: 'first', signature: 'sig_1' }, + { type: 'redacted_thinking', data: 'encrypted_1' }, { type: 'thinking', thinking: 'second', signature: 'sig_2' }, { type: 'tool_use', id: 'toolu_01A', name: 'get_weather', input: { location: 'SF' } }, ], diff --git a/src/llm/__tests__/gemini-client.test.ts b/src/llm/__tests__/gemini-client.test.ts index e0a7bf5..70d3860 100644 --- a/src/llm/__tests__/gemini-client.test.ts +++ b/src/llm/__tests__/gemini-client.test.ts @@ -189,13 +189,13 @@ describe('Gemini Interactions native tool calling', () => { }); it.each([ - [{ type: 'function_call', id: 'call_bad', name: 'get_weather', arguments: 'not-an-object' }], - [{ type: 'model_output', content: [{ type: 'text' }] }], - [{ type: 'thought', signature: 'sig_bad', summary: [{ type: 'text', text: 42 }] }], - ])('rejects malformed known response steps', async (steps) => { + { type: 'function_call', id: 'call_bad', name: 'get_weather', arguments: 'not-an-object' }, + { type: 'model_output', content: [{ type: 'text' }] }, + { type: 'thought', signature: 'sig_bad', summary: [{ type: 'text', text: 42 }] }, + ])('rejects malformed known response steps', async (step) => { const store = new InMemorySecretStore([[llmProviderSecretRef('gemini'), 'gemini-key']]); const client = await createGeminiClientFromProfile(profile, store, { - fetch: fakeGeminiFetch({ id: 'interaction_bad', status: 'requires_action', steps }), + fetch: fakeGeminiFetch({ id: 'interaction_bad', status: 'requires_action', steps: [step] }), }); await expect(client.complete([{ role: 'user', content: [textContent('Weather?')] }], [weatherTool])).rejects.toThrow(); diff --git a/src/llm/anthropic.ts b/src/llm/anthropic.ts index 48e29cd..8250518 100644 --- a/src/llm/anthropic.ts +++ b/src/llm/anthropic.ts @@ -149,12 +149,14 @@ function toAnthropicMessage(profile: LLMProfile, message: Message): Record[] { - const blocks: Record[] = message.thinking_blocks - .filter( - (block): block is Extract & { signature: string } => - block.type === 'thinking' && block.signature !== null, - ) - .map((block) => ({ type: 'thinking', thinking: block.thinking, signature: block.signature })); + const blocks: Record[] = []; + for (const block of message.thinking_blocks) { + if (block.type === 'redacted_thinking') { + blocks.push({ type: 'redacted_thinking', data: block.data }); + } else if (block.signature !== null) { + blocks.push({ type: 'thinking', thinking: block.thinking, signature: block.signature }); + } + } const text = reduceTextContent(message); if (text.length > 0) { @@ -221,8 +223,14 @@ function parseAnthropicMessagesResponse(raw: unknown): LLMCompletionResponse { .filter((block): block is AnthropicTextBlock => block.type === 'text') .map((block) => block.text) .join('\n'); - const thinkingBlocks = parsed.content.filter((block): block is AnthropicThinkingBlock => block.type === 'thinking'); - const reasoningContent = thinkingBlocks.map((block) => block.thinking).join(''); + const thinkingBlocks = parsed.content.filter( + (block): block is AnthropicThinkingBlock | AnthropicRedactedThinkingBlock => + block.type === 'thinking' || block.type === 'redacted_thinking', + ); + const reasoningContent = thinkingBlocks + .filter((block): block is AnthropicThinkingBlock => block.type === 'thinking') + .map((block) => block.thinking) + .join(''); const toolUseBlocks = parsed.content.filter((block): block is AnthropicToolUseBlock => block.type === 'tool_use'); const toolCalls = toolUseBlocks.map(fromAnthropicToolUse); @@ -232,11 +240,9 @@ function parseAnthropicMessagesResponse(raw: unknown): LLMCompletionResponse { content: text, tool_calls: toolCalls.length > 0 ? toolCalls : null, reasoning_content: reasoningContent.length > 0 ? reasoningContent : null, - thinking_blocks: thinkingBlocks.map((block) => ({ - type: 'thinking', - thinking: block.thinking, - signature: block.signature ?? null, - })), + thinking_blocks: thinkingBlocks.map((block) => block.type === 'thinking' + ? { type: 'thinking', thinking: block.thinking, signature: block.signature ?? null } + : { type: 'redacted_thinking', data: block.data }), }, usage: parsed.usage === null ? null : { promptTokens: parsed.usage.input_tokens, @@ -281,6 +287,9 @@ const anthropicTextBlockSchema = z.object({ type: z.literal('text'), text: z.str const anthropicThinkingBlockSchema = z .object({ type: z.literal('thinking'), thinking: z.string(), signature: z.string().nullable().optional() }) .passthrough(); +const anthropicRedactedThinkingBlockSchema = z + .object({ type: z.literal('redacted_thinking'), data: z.string() }) + .passthrough(); const anthropicToolUseBlockSchema = z .object({ type: z.literal('tool_use'), @@ -289,19 +298,21 @@ const anthropicToolUseBlockSchema = z input: z.record(z.string(), z.unknown()), }) .passthrough(); -const knownAnthropicBlockTypes = new Set(['text', 'thinking', 'tool_use']); +const knownAnthropicBlockTypes = new Set(['text', 'thinking', 'redacted_thinking', 'tool_use']); const anthropicOtherBlockSchema = z .object({ type: z.string().refine((type) => !knownAnthropicBlockTypes.has(type)) }) .passthrough(); const anthropicContentBlockSchema = z.union([ anthropicTextBlockSchema, anthropicThinkingBlockSchema, + anthropicRedactedThinkingBlockSchema, anthropicToolUseBlockSchema, anthropicOtherBlockSchema, ]); type AnthropicTextBlock = z.infer; type AnthropicThinkingBlock = z.infer; +type AnthropicRedactedThinkingBlock = z.infer; type AnthropicToolUseBlock = z.infer; const anthropicMessagesResponseSchema = z diff --git a/src/llm/gemini.ts b/src/llm/gemini.ts index 355dd32..d3e742d 100644 --- a/src/llm/gemini.ts +++ b/src/llm/gemini.ts @@ -297,7 +297,9 @@ function isJsonObject(value: unknown): value is JsonObject { } const geminiTextContentSchema = z.object({ type: z.literal('text'), text: z.string() }).passthrough(); -const geminiOtherContentSchema = z.object({ type: z.string() }).passthrough(); +const geminiOtherContentSchema = z + .object({ type: z.string().refine((type) => type !== 'text') }) + .passthrough(); const geminiContentSchema = z.union([geminiTextContentSchema, geminiOtherContentSchema]); const geminiModelOutputStepSchema = z .object({ type: z.literal('model_output'), content: z.array(geminiContentSchema).default([]) })