← Engel Nyst
OpenHands/software-agent-sdk PR #3673 · show-me

A second opinion, without the agent switching its own model

PR #3673 adds a built-in ask_oracle tool. When the agent is stuck or wants a stronger reviewer, it consults a saved LLM profile named oracle for a one-shot answer — stateless: no conversation history, no tools, and the agent's own active model is never changed.

Head SHA b977f4d5 (after merging main). All source links below pin this SHA. Prepared by OpenHands (AI) on behalf of the user.

1What it adds: a callable "ask a smarter model" button

★ Key takeaway: ask_oracle gives the agent a way to borrow a stronger model for a single question and then keep going on its own model. The Oracle is just a saved LLM profile named oracle — no new settings field, no wiring; save the profile and the tool works.
1new tool
5feature files
0new settings fields
read-onlytool annotation

The agent adds it like any other tool — Tool(name="ask_oracle") — and calls it with a question and optional context. The tool answers from the oracle profile and hands the text back as an observation, so the agent can fold that advice into its next reply.

Definition: AskOracleAction — definition.py:28-53 · AskOracleTool.create — definition.py:87-115

2The model you'd expect — and the one that's true

The natural first reaction is "isn't this just switching the model, or spawning a subagent?" The SDK already has both of those, and ask_oracle is deliberately neither. The whole design is in the difference.

Three ways to use another model, and how ask_oracle differs switch_llm changes the active model for all future turns; delegate runs a full tool-using sub-agent; ask_oracle does a single stateless completion against the oracle profile and leaves the active model unchanged. switch_llm delegate ask_oracle active model = A before the call active model = B for ALL later turns persistent swap active model = A unchanged sub-agent (model B) own tools · own history runs a whole loop active model = A ✓ stays A oracle profile (B) 1 completion · no tools no history answer returns; agent keeps model A
Fig 1 · Only ask_oracle both leaves the active model untouched and avoids spinning up a full agent. It borrows the stronger model for exactly one answer.
ⓘ Read it this way: the green marks on the bottom row are the point — the agent's own model (A) is the same before and after the consult. Contrast: switch_llm.py:151 · delegate/definition.py:16

3How one consult flows, step by step

The executor is small and does exactly four things: resolve the profile, build two messages, call the model once, return the text. Everything that makes it "stateless" is a thing it deliberately does not pass along.

ask_oracle executor data flow The agent emits an AskOracleAction; the executor loads the oracle profile from the LLM profile store using the conversation cipher, builds an oracle system prompt plus the question, calls make_llm_completion once with no tools and no history, and returns the text as an observation. Agent (model A) emits AskOracleAction AskOracleExecutor __call__ · impl.py:44 no side effects on state LLMProfileStore .load("oracle", cipher) 2 messages oracle prompt + question make_llm_completion model B · one call AskOracleObservation (text) → agent continues on model A NOT passed: history · tools · agent's LLM
Fig 2 · The two accented boxes are the whole tool; the dashed orange box names what it withholds — that omission is the "stateless" property.
StepWhat happensSource
1Resolve the Oracle: load the profile named oracle, decrypting stored secrets with the conversation's cipher.impl.py:49-51
2Build exactly two messages: the fixed Oracle system prompt + the agent's question (and optional context).impl.py:71-86
3One completion — make_llm_completion(oracle_llm, messages), no tools, no prior turns.impl.py:88-89
4Return the text as an observation; on any failure return an error observation instead of raising.impl.py:99-113
ⓘ Non-example (what does not happen): the executor never mutates conversation state, never registers the Oracle LLM as the agent's model, and never gives the Oracle any tools. If the oracle profile is missing, it returns a friendly error observation (impl.py:52-59) — it does not crash the turn.

4The surface: five small files, all additive

This is a pure addition — no existing behavior is rewritten, so there is no "before" to redraw. The table is the whole feature.

FileRoleLink
🟢 ask_oracle/definition.pyAction, observation, tool schema, read-only annotation, and self-registration.definition.py
🟢 ask_oracle/impl.pyThe executor — the four-step flow in §3.impl.py
🟢 ask_oracle/__init__.pyPublic exports for the sub-package.__init__.py
✏️ tools/__init__.pyRe-export AskOracleTool at the top level.__init__.py:21
🟢 example 56_ask_oracle_tool/main.pyEnd-to-end: save two profiles, add the tool, drive a real consult.main.py

5"Oracle" is a convention, not configuration

An earlier revision of this PR added an oracle_llm_profile settings field; it was dropped in favor of a single fixed name. The tool looks up the profile literally called oracleORACLE_PROFILE_NAME = "oracle" · definition.py:25. To enable the Oracle, you just save that profile:

store = LLMProfileStore()
store.save("oracle", LLM(model="a-stronger-model", api_key=...))
agent = Agent(llm=..., tools=[Tool(name="ask_oracle")])

That keeps the agent schema untouched (no new settings to migrate) and makes the Oracle swappable at rest — point the oracle profile at any model without changing agent config. The example wires exactly this: main.py:37-61.

⚠ Boundary: the Oracle profile can hold API keys. Loading uses the conversation's cipher so secrets stay encrypted at rest and are only decrypted in-process for the single call — the executor takes conversation._cipher rather than reading plaintext secrets itself (impl.py:49-51).

6Main-merge note: what changed to land on current main

The branch was ~190 commits behind. Merging main touched the feature in three small, mechanical ways — the tool logic itself was unaffected.

Merge issueResolutionWhere
Conflict in settings/model.py create_agent()Took main's refactor (new default_tool_specs defaulting, self.mcp_config passthrough); the branch's incidental edits here were superseded.model.py:1309-1348
Example number collided with main's 55_persistent_memoryRenumbered the example 55 → 56; the duplicate-example check now passes.56_ask_oracle_tool/
Test drift from main's API changesAssert on the live conversation.agent.tools_map (plugin load now replaces the agent with a copy), and add the new call_context param to the test LLM's completion override.test_ask_oracle.py

7Proof and current status

CheckResult
pytest tests/tools/ask_oracle/test_ask_oracle.py6 passed
pytest test_examples.py::test_directory_example_is_discoveredpassed (example 56 discovered)
python .github/scripts/check_duplicate_example_numbers.pyno duplicates
pytest test_openapi_contract.py test_openapi_discriminator.py13 passed (AskOracleAction in the Action union)
pyright (ask_oracle pkg + example + model.py) · pre-commit hooksclean

The PR is mergeable after the merge; remaining CI/approval gating is normal branch protection.

Feature diff (five files, additive)
examples/01_standalone_sdk/56_ask_oracle_tool/main.py   | +77
openhands-tools/openhands/tools/ask_oracle/__init__.py  | +36
openhands-tools/openhands/tools/ask_oracle/definition.py| +119
openhands-tools/openhands/tools/ask_oracle/impl.py      | +113
openhands-tools/openhands/tools/__init__.py             | +2
tests/tools/ask_oracle/test_ask_oracle.py               | +208
← Back to Engel Nyst · Grounded to PR #3673 at b977f4d5.