Skip to content

docs(examples): show how to use DecisionModel - #6339

Open
jpshackelford wants to merge 5 commits into
mainfrom
feat/decision-model-examples
Open

jpshackelford wants to merge 5 commits into
mainfrom
feat/decision-model-examples

Conversation

@jpshackelford

@jpshackelford jpshackelford commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

HUMAN:

Examples to demonstrate the new DecisionModel API.


AGENT:

Why

#6068 adds DecisionModel, but no example shows how to call it or how to design questions for it. A decision model is used differently from an LLM. It works best when you ask many narrow questions about one state in a single call, and when your code acts on the answers and their confidence. The TypeSafe docs for Jev say this, and the examples follow that guidance. Both examples triage GitHub issues, the same kind of task the other SDK examples give to an agent.

Summary

  • 60_decision_model.py triages one issue in a single decide() call that asks six Noul, Choice and Score questions. Some of those questions only matter for some kinds of issue. The kind Choice uses structured options: each says what it covers, what it does not, and gives examples, so bug reports and feature requests are easier to tell apart. The code then reads the typed answers, routes with Noul thresholds and confidence gates, and logs the model version, tokens and cost.
  • 61_decision_model_agent_gate.py puts a decision model in front of an agent. It triages a queue concurrently with adecide(), one request per issue, and sends only the small, clear issues to the agent. Then it makes a second request about the agent's reply. That request has to be separate because the reply did not exist when the first request ran.
  • 62_decision_model_custom_provider.py lists the decision models LiteLLM supports. Then it subclasses DecisionModel and overrides send() and asend() to call TypeSafe's HTTP API directly. Both routes answer the same questions, and the code that reads the answers is the same for both. Its send() raises LLMResponseError for a reply it cannot read, as DecisionModel does for answers of the wrong type. For generally available models, it recommends contributing a provider to LiteLLM instead, so SDK users get it when the SDK takes that LiteLLM release.
  • 27_observability_laminar.py now says that decision-model calls emit decision.<model> spans. Example 61 wraps each triage in an @observe span, so the decision span nests under it.

Issue Number

Refs #6015

Docs: OpenHands/docs#1054 documents all three examples, on a docs branch with the same name as this one.

How to Test

Set JEV_API_KEY, then run uv run python examples/01_standalone_sdk/60_decision_model.py. Example 61 also needs LLM_API_KEY (and LLM_MODEL / LLM_BASE_URL if you use a proxy).

  • Example 60, live Jev (jev-1.14.0) — One call answered all six questions with 788 input tokens, for $0.000033. The severity Score came back at 1.48 with confidence 0.28, so the confidence gate did not add priority:high.
  • Structured vs plain kind options — On four short issues, both versions picked the same kind. Confidence rose on the two ambiguous ones: "should support paths with spaces" went from 0.44 to 0.72, and "Why does the agent ignore my LLM_MODEL setting?" from 0.64 to 0.72. The two clear issues stayed at 0.97 and 1.00.
  • Example 61, live Jev plus claude-haiku-4-5 — The four issues went to the agent, needs more information, security team and human planning. The agent created slug.py. The review request returned claims_done=0.97 and left_work_undone=0.38, which is in the unsure band, so the code sent the issue to a human. Decision calls cost $0.00012 and the agent cost $0.038. A re-run on 2eb3abbc3 routed the issues the same way. That time the agent's reply was clear (left_work_undone=0.05), so the issue went to ready for review. Between the two runs, both outcomes of the review step have run live.
  • Example 62, live Jev — The direct HTTP route (sync and async) and the LiteLLM route returned the same kind of answer, area and model (jev-1.14.0): is_bug 0.96, area import_export, severity 1.20 to 1.25 across runs. Jev's numbers vary by about 0.02 to 0.03 between identical requests. With a local OTLP collector, each of the three calls exported a decision.<model> span with request model, response model and token counts. Only the LiteLLM span has a cost, because TypeSafe's own API reports no price. The spans were checked again on 2eb3abbc3.
  • OTEL — I ran the triage step against a local OTLP collector. Each of the four decision.typesafe/jev-latest spans was a child of its triage_issue span and had the question, answer, token and cost attributes. triage_issue had no input attribute, so the issue text stayed off the trace.
  • Re-test on 70245fdc8 (review fixes) — I rebased onto it and ran all three examples live with a fresh local collector per run. Routes and answers matched the earlier runs. Example 61 exported five decision spans, and each of the four triage spans was a child of its triage_issue span. No span recorded the state. Through both LiteLLM and example 62's subclass, a Choice whose options have no description and a Noul with only criteria both worked. LiteLLM's limits (129 questions, 256 options) raised LLMBadRequestError before sending. A bad API key on the direct route raised the original HTTPStatusError after one request, with no retry. On the LiteLLM route it raised LLMAuthenticationError, with LiteLLM's error as its cause. When the subclass returned an answer nobody asked for, or one of the wrong type, decide() raised LLMResponseError. In test_decision.py, 35 tests pass and coverage of decision.py is 98%. The 1,473 tests in the LLM, observability and examples suites pass.
  • Example test discovery — uv run pytest tests/examples/test_examples.py passes. The three new examples are in _EXCLUDED_EXAMPLES because CI does not set JEV_API_KEY.

Video/Screenshots

None.

Design Doc

None.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

To run these examples in the Run Examples Scripts workflow, add a JEV_API_KEY repository secret, pass it in run-examples.yml, and remove the three lines from _EXCLUDED_EXAMPLES. The optional "Docs example" check passes because OpenHands/docs#1054 documents all three examples.

This PR was drafted by an AI agent on behalf of the user.

@jpshackelford can click here to continue refining the PR


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:5baa6ce-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-5baa6ce-python \
  ghcr.io/openhands/agent-server:5baa6ce-python

All tags pushed for this build

ghcr.io/openhands/agent-server:5baa6ce-golang-amd64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-golang-amd64
ghcr.io/openhands/agent-server:feat-decision-model-examples-golang-amd64
ghcr.io/openhands/agent-server:5baa6ce-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:5baa6ce-golang-arm64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-golang-arm64
ghcr.io/openhands/agent-server:feat-decision-model-examples-golang-arm64
ghcr.io/openhands/agent-server:5baa6ce-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:5baa6ce-java-amd64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-java-amd64
ghcr.io/openhands/agent-server:feat-decision-model-examples-java-amd64
ghcr.io/openhands/agent-server:5baa6ce-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:5baa6ce-java-arm64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-java-arm64
ghcr.io/openhands/agent-server:feat-decision-model-examples-java-arm64
ghcr.io/openhands/agent-server:5baa6ce-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:5baa6ce-python-amd64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python-amd64
ghcr.io/openhands/agent-server:feat-decision-model-examples-python-amd64
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime-amd64
ghcr.io/openhands/agent-server:5baa6ce-python-arm64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python-arm64
ghcr.io/openhands/agent-server:feat-decision-model-examples-python-arm64
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime-arm64
ghcr.io/openhands/agent-server:5baa6ce-python-minimal-amd64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python-minimal-amd64
ghcr.io/openhands/agent-server:feat-decision-model-examples-python-minimal-amd64
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:5baa6ce-python-minimal-arm64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python-minimal-arm64
ghcr.io/openhands/agent-server:feat-decision-model-examples-python-minimal-arm64
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:5baa6ce-python-slim-amd64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python-slim-amd64
ghcr.io/openhands/agent-server:feat-decision-model-examples-python-slim-amd64
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:5baa6ce-python-slim-arm64
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python-slim-arm64
ghcr.io/openhands/agent-server:feat-decision-model-examples-python-slim-arm64
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:5baa6ce-golang
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-golang
ghcr.io/openhands/agent-server:feat-decision-model-examples-golang
ghcr.io/openhands/agent-server:5baa6ce-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:5baa6ce-java
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-java
ghcr.io/openhands/agent-server:feat-decision-model-examples-java
ghcr.io/openhands/agent-server:5baa6ce-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:5baa6ce-python-minimal
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python-minimal
ghcr.io/openhands/agent-server:feat-decision-model-examples-python-minimal
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime-minimal
ghcr.io/openhands/agent-server:5baa6ce-python-slim
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python-slim
ghcr.io/openhands/agent-server:feat-decision-model-examples-python-slim
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime-slim
ghcr.io/openhands/agent-server:5baa6ce-python
ghcr.io/openhands/agent-server:5baa6cea982f88c2c29783b84f3f5fc1fbd5e2cd-python
ghcr.io/openhands/agent-server:feat-decision-model-examples-python
ghcr.io/openhands/agent-server:5baa6ce-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., 5baa6ce-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 5baa6ce-python-amd64) are also available if needed

@jpshackelford
jpshackelford added this pull request to stack #6069 October 30, 2026 06:38
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@jpshackelford
jpshackelford marked this pull request as ready for review October 30, 2026 06:39
@jpshackelford
jpshackelford requested a review from enyst October 30, 2026 06:40
@jpshackelford
jpshackelford force-pushed the feat/decision-model-examples branch 2 times, most recently from 3da2d5c to 8c45c48 Compare October 30, 2026 07:25
@jpshackelford

jpshackelford commented Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

How these examples were tested

CI does not run the three examples, because it has no JEV_API_KEY. So I ran each one by hand against live services, from this branch on top of #6068 (d202caf), with LiteLLM 1.105.1. Every run below used the current code.

Live runs

Example Services What I checked Result
60_decision_model.py Jev (typesafe/jev-latest) One decide() call answers all six questions. The routing code reads the answers and gates on Noul thresholds and confidence. The run reports the model version, tokens and cost. kind=bug (confidence 1.00), severity 1.48 with confidence 0.28, so no priority:high. Answered by jev-1.14.0, 788 input tokens, $0.000033.
61_decision_model_agent_gate.py Jev, plus claude-haiku-4-5 through the LLM proxy Concurrent adecide() triage of four issues sends each one down a different route. The agent works only on the issue it receives. A second decision call reviews the agent's reply. The four issues went to the agent, needs more information, security team and human planning. The agent created slug.py. The review call returned claims_done=0.97 and left_work_undone=0.35, which is in the unsure band, so the code sent the issue to a human. Total cost $0.039, almost all of it the agent.
62_decision_model_custom_provider.py Jev, through TypeSafe's HTTP API and through LiteLLM The DecisionModel subclass works with decide() and adecide(), and gives the same answers as the LiteLLM route through the same reading code. All three calls gave is_bug 0.96, area import_export, severity 1.20 to 1.22, model jev-1.14.0.

Structured options vs plain strings

For the structured kind Choice in example 60, I asked a plain-string version and the structured version about four short issues, in the same call. Both versions chose the same kind for every issue. Confidence rose on the two ambiguous issues, from 0.44 to 0.72 and from 0.64 to 0.72, and did not change on the two clear ones (0.97 and 1.00). Four issues is a sanity check, not a benchmark.

OpenTelemetry spans

I ran a small local OTLP/HTTP collector, a stdlib HTTPServer that decodes ExportTraceServiceRequest. Then I pointed the examples at it:

OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://127.0.0.1:4318/v1/traces \
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf \
uv run python examples/01_standalone_sdk/62_decision_model_custom_provider.py
  • Example 61: each of the four decision.typesafe/jev-latest spans was a child of its triage_issue span. Each had the question, answer, token and cost attributes. triage_issue had no input attribute, so the issue text stayed out of the trace.
  • Example 62: all three calls exported a decision.<model> span with gen_ai.request.model, gen_ai.response.model (jev-1.14.0) and token counts. That includes the two calls made through the subclass, so a custom provider gets spans too. Only the LiteLLM span has gen_ai.usage.cost, because TypeSafe's own API reports no price.

The SDK assumes gRPC unless OTEL_EXPORTER_OTLP_TRACES_PROTOCOL is set, and the HTTP exporter sends gzip bodies. Anyone repeating this with a hand-rolled collector needs to allow for both.

Repository checks

  • uv run pre-commit run passes on all changed files (ruff format, ruff lint, pyright and the other hooks).
  • uv run pytest tests/examples/test_examples.py passes. All three examples are in _EXCLUDED_EXAMPLES because CI has no JEV_API_KEY.
  • check-examples passes now that docs(sdk): add decision model guides docs#1054 documents all three examples. That PR is on a docs branch with the same name, which is the branch the job checks out.

Re-check on 2eb3abbc3

2eb3abbc3 moves the shared question fields into a base class and removes from __future__ import annotations. Constructor arguments and fields are unchanged, so the examples and docs needed no edits. The one visible effect is that serialized questions now list instructions before type.

I rebased this branch onto it, which changed no example or test file, and checked again:

  • All three examples ran live. Example 61 routed the four issues the same way. This time the agent's reply was clear (left_work_undone=0.05), so the issue went to ready for review. Between this run and the first, both outcomes of the review step have run live.
  • Example 62 gave the same answers on both routes. Severity was 1.23 to 1.25 this time, against 1.20 to 1.22 before.
  • I ran the old and new commits side by side on the same questions. Jev varied by about 0.02 between identical requests on both, so the new key order makes no difference I can measure.
  • All three decision.<model> spans still reached the local collector.
  • test_decision.py covers decision.py at 98%, the same three lines are uncovered, and the 1,459 LLM and observability tests pass.

Re-test on 70245fdc8 (review fixes)

70245fdc8 changes behavior the examples rely on:

  • Limit errors are now LLMBadRequestError, and there is a new 255-option limit on Choice.
  • instructions and Choice option descriptions are optional.
  • Answers of the wrong type, or to questions nobody asked, now raise LLMResponseError.
  • Unmapped errors keep their original exception.
  • result.metrics is documented as running totals.
  • There are new LiteLLM settings: custom_llm_provider, extra_headers, safety_identifier and AWS credentials.

I rebased this branch and made two small example edits (04defaebd):

  • Example 60's docstring says what happens past the limits.
  • Example 62's send() raises LLMResponseError instead of ValueError for a reply it cannot read.

None of the examples misused result.metrics; they read the running totals from decision_model.metrics. Then I tested again:

Check Result
Example 60, live Same route and labels. One span with questions and answers, and no state.
Example 61, live, plus OTLP Same four routes. Five decision spans; each of the four triage spans was a child of its triage_issue span. No span recorded the state. This time the agent's reply was hedged (left_work_undone=0.50), so it went to a human.
Example 62, live, plus OTLP Direct sync, direct async and LiteLLM all gave is_bug 0.96 and area import_export. Three spans, and only the LiteLLM one has a cost.
New optional fields, both routes A Choice whose options have no description, and a Noul with only criteria, were accepted by LiteLLM and by TypeSafe's API directly.
LiteLLM limits 129 questions, and 256 options in one Choice, each raised LLMBadRequestError before any request.
Bad API key, direct route Raised the original httpx.HTTPStatusError (401) after one request. No retry, and no wrapper.
Bad API key, LiteLLM route Raised LLMAuthenticationError, with LiteLLM's AuthenticationError as its cause.
Subclass returns an extra answer, or a wrong type LLMResponseError: "answers nobody asked for: …" and "wrong answer type for: is_bug (ScoreAnswer, expected NoulAnswer)".
Docs snippets (taken from the docs PR pages) Both run live.
Unit tests test_decision.py 35 passed; decision.py coverage 98%. The LLM, observability and examples suites: 1,473 passed.

To repeat the span checks, start a fresh collector process for each run. If you empty its log file while it is still running, the next span is written after a run of NUL bytes on the first line. A line-based grep then misses that span. That is how I first counted one decision span too few, and why I re-ran with a new collector each time.

This comment was drafted by an AI agent on behalf of the user.

@jpshackelford
jpshackelford force-pushed the feat/decision-model-examples branch 3 times, most recently from 04defae to aa06c83 Compare October 30, 2026 13:17
Base automatically changed from feat/decision-model to main October 30, 2026 14:48
Add 60_decision_model.py (one call, many typed questions, routing in code)
and 61_decision_model_agent_gate.py (concurrent adecide() triage in front of
an agent, plus a second request that reviews the agent's reply). Point the
Laminar example at decision-model spans. Exclude both from the CI example run,
which has no JEV_API_KEY.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
…example

Describe each kind of issue with what it covers, what it does not, and
examples, so bug reports and feature requests are easier to tell apart.

Co-authored-by: openhands <openhands@all-hands.dev>
Example 62 lists the decision models LiteLLM supports, then subclasses
DecisionModel and overrides send() and asend() to call TypeSafe's HTTP API
directly. It asks both routes the same questions with the same reading code,
and recommends contributing generally available providers to LiteLLM.
Example 60 now prints result.model instead of reading LiteLLM's response.

Co-authored-by: openhands <openhands@all-hands.dev>
Example 60 says that going over LiteLLM's limits (128 questions, 255 options
per Choice) raises LLMBadRequestError before anything is sent. Example 62's
send() raises LLMResponseError for an answer type it cannot read, as
DecisionModel does for answers of the wrong type, and its docstring notes
that decide() checks the answers.

Co-authored-by: openhands <openhands@all-hands.dev>
@jpshackelford
jpshackelford force-pushed the feat/decision-model-examples branch from aa06c83 to 5baa6ce Compare October 30, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants