Skip to content

feat(decisions): add an OpenAI Decisions backend for the decision slots - #7127

Merged
WillemJiang merged 2 commits into
bytedance:mainfrom
lisir202446:feat/openai-decisions-backend
Oct 20, 2026
Merged

WillemJiang merged 2 commits into
bytedance:mainfrom
lisir202446:feat/openai-decisions-backend

Conversation

@lisir202446

Copy link
Copy Markdown
Contributor

Fixes #7005

Why

The three decision workflows — the tool risk gate, the memory pre-screen and signal classification — can today only be answered by TypeSafe (Jev). This adds OpenAI Decisions (POST /v1/decisions) as a second backend behind the interfaces that already exist, following the issue's framing: reuse the current workflows and policy logic rather than design a new decision system.

What changed

  • deerflow/openai_decisions/client.py — the dialect: the request skeleton (input, plus a questions array rather than a mapping) and the two structural differences. A predicate question has no criteria field, so the true/false rubric folds into instructions; a refusal is an answer type, so it becomes a per-question QuestionError rather than an HTTP failure. Answers are validated into the existing NoulAnswer / ChoiceAnswer / AnswerSet shapes, which is what lets every consumer keep its own threshold and failure direction untouched.
  • Three adapters — guardrails/openai_decisions.py, agents/memory/prescreen/openai_decisions.py, agents/memory/signals/openai_decisions.py — subclass the Jev adapters and override exactly three things: which service the connection resolves against, which client carries it, and the value the endpoint judges (input text instead of a state object). Thresholds, rubrics, caches, per-question fallback, request_failed reporting, sharing identity and audit stay in the base classes, because two copies of a security boundary drift.
  • deerflow.typesafe gains the two seams a dialect actually needs: provider_label, so a request-level message names the service the operator configured, and TypeSafeConnection.endpoint_path, so the endpoint path belongs to the backend that resolves the connection (https://api.openai.com + /v1/systemone can no longer be composed). Retry and backoff, the deadline budget, the bounded never-decoded body read and the error taxonomy are inherited, not restated. resolve_decisions_connection pairs the two, so a Decisions connection cannot be built pointing at System One.

No config schema is added — wiring is the existing use: class path:

guardrails:
  enabled: true
  fail_closed: true
  provider:
    use: deerflow.guardrails.openai_decisions:OpenAIDecisionsGuardrailProvider
    config:
      api_key_env: OPENAI_API_KEY
      model: <the decision model this deployment names>   # required; see below
      threshold: 0.5

model deliberately ships no default: which decision model answers is an operator choice with a cost and a data-handling implication, so construction fails loudly rather than quietly sending conversation or tool text to a model nobody selected. The memory sides wire the same way (memory.backend_config.prescreen.use / signal_classification.use), documented in backend/docs/GUARDRAILS.md and config.example.yaml.

Verification

  • New offline suite backend/tests/test_openai_decisions.py (38 tests, no network or credentials, running through httpx.MockTransport the way the Jev suites do like the Jev suites) covering both mapping directions and each consumer's direction: the gate denies at or above threshold and maps a refusal to OpenAIDecisionsGuardrailError (invalid_response), the pre-screen treats a refusal as "no opinion" so the batch still extracts, and a refused direction does not cost the other direction's label in the same response.
  • Claims come in pairs, not one-sided: rubric folded vs. choice instructions untouched; first duplicate name wins; response model present vs. absent; non-200 / non-array / oversized / compressed bodies all stay request-level failures.
  • Mutation check, six arms, all caught: criteria not folded into instructions → 1 red; model required in the response → 1 red; last duplicate name wins → 1 red; refusal raised as a request-level failure instead of a per-question one → 2 red; probability read from the wrong field → 12 red; endpoint path reverted to /v1/systemone → 3 red.
  • The existing Jev path is behaviour-neutral under the refactor: test_typesafe_client, test_typesafe_config, test_typesafe_guardrail_provider, test_memory_prescreen, test_app_config_reload and tests/blocking_io/test_typesafe_guardrail_provider → 339 passed with the seams in place and the new suite on top.
  • Full offline suite on this branch (feat/openai-decisions-backend @ bb00a68, base origin/main@fee0cd0): 34,344 passed / 709 skipped / 0 failed across the four shards; strict blocking-io gate: 262 passed / 1 skipped.
  • ruff check . / ruff format --check . clean; guidance check (--base-ref fee0cd0 --head-ref bb00a68): 0 errors, 0 warnings.

Out of scope

  • score questions and the levels / confidence fields: no DeerFlow consumer asks them today, and the shared Question type has no such shape.
  • Nothing here was exercised against the live provider; the contract is implemented from the published /v1/decisions request/response shape and every test is offline.
  • The top-level typesafe: block is deliberately not reused as this backend's shared defaults — it would put an OpenAI endpoint's defaults under a TypeSafe name. Each side names its own connection.

Issue bytedance#7005 asked for a second decision provider behind the interfaces the
TypeSafe (Jev) integration already uses, not a new decision system.
`deerflow/openai_decisions` speaks `POST /v1/decisions`: a `noul` question
becomes a `predicate` whose true/false rubric folds into `instructions` (the
endpoint has no criteria field, and dropping it would move the decision
boundary while the adapter kept reporting the same criteria in its policy
identity), answers arrive as an array rather than a mapping, and a refusal is an
answer `type` rather than an HTTP failure -- so it lands in the existing
per-question error channel and each consumer keeps its own failure direction.

Transport is reused rather than copied. `provider_label` and the connection's
`endpoint_path` are the only two seams a dialect needs, so retry and backoff,
the deadline budget, the bounded never-decoded body read and the error taxonomy
keep one owner; `resolve_decisions_connection` pairs the two so a Decisions
connection cannot be built pointing at System One. The three adapters override
their connection resolution, their client, and the value the endpoint judges
(`input` text instead of a `state` object), and inherit thresholds, rubrics,
caches and audit policy unchanged. Wiring stays a `use:` class path, so no
config schema is added.
@github-actions github-actions Bot added area:agents Agents, subagents, graph wiring, prompts, langgraph.json area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines labels Oct 19, 2026
#: Which request path the endpoint serves. It belongs to the *dialect*, not the operator:
#: a connection whose base URL points at another service must not report a System One URL, so
#: every consumer resolves it through the backend it is about to call.
endpoint_path: str = ENDPOINT_PATH

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include the endpoint path in the shared-request identity

Adding endpoint_path also needs to affect TypeSafeClient.sharing_key(): it hashes public_parameters(), which currently omits this field. If a TypeSafe prescreen and a Decisions classifier use the same gateway base URL, model, credential and other shared settings, combine="auto" treats them as the same backend despite /v1/systemone and /v1/decisions using different protocols.

Minimal identity reproduction at bb00a68f (construction only; no network requests):

from deerflow.agents.memory.prescreen.typesafe import TypeSafeMemoryPrescreen
from deerflow.agents.memory.signals.openai_decisions import OpenAIDecisionsSignalClassifier

settings = dict(
    api_key="synthetic-review-key",
    model="proxy-model",
    base_url="https://decision-gateway.example",
)
prescreen = TypeSafeMemoryPrescreen(**settings)
classifier = OpenAIDecisionsSignalClassifier(**settings)
assert prescreen._connection.url != classifier._connection.url
assert prescreen.sharing_key() != classifier.sharing_key()  # fails

I also reproduced the downstream effect using production MemorySignalCoordinator with one shared HTTPX MockTransport factory: auto-combining sends all three questions to /v1/systemone, never calls Decisions, and produces skip=True. With the same endpoint responses and combine="never", both configured endpoints are queried and the Decisions reinforcement hint vetoes the skip (skip=False). Temporarily including endpoint_path in the identity restores that behavior with auto too.

Please include the endpoint path/full target URL or dialect in the sharing identity, and add a same-gateway mixed-backend regression. The current test_the_two_backends_never_share_one_combined_request uses different default base URLs and different transport factories, so it cannot expose this collision. The existing decision/shared-client suites passed (108 tests); the additional routing regression fails on this head, while the separate-request control and process-local identity correction pass.

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at bb00a68. The dialect/subclass split is the right shape: the security boundary (bounded never-decoded body, identity encoding, retry/deadline budget, credential handling) stays in deerflow.typesafe, the three adapters override only connection resolution, client construction and the judged value, and the new offline suite pins both mapping directions — including the digest-vs-input bytes identity on the gate.

I independently verified the open P2 thread from @wswsadadbaba123 and agree it is real: TypeSafeConnection.public_parameters() — which TypeSafeClient.sharing_key() hashes — omits endpoint_path, so with combine: auto a Jev prescreen and a Decisions classifier sharing one gateway base URL, model, credential and transport factory collide into one combined request served by whichever side is eligible (MemorySignalCoordinator._judge_combined), and test_the_two_backends_never_share_one_combined_request cannot catch it because its two sides default to different base URLs. Leaving that to the existing thread.

Two smaller findings inline: construction-time errors raised through resolve_decisions_connection still name TypeSafe (exactly the misattribution provider_label was added to prevent), and the new refusal category extends a category set that the shared QuestionError docstring still enumerates as four.

return {"base_url": DEFAULT_BASE_URL, "api_key_env": DEFAULT_API_KEY_ENV, "model": ""}


def resolve_decisions_connection(*, settings: Mapping[str, object] | None = None, defaults: Mapping[str, object] | None = None, configuration_source: str) -> TypeSafeConnection:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Construction-time errors for this backend still name "TypeSafe"

resolve_decisions_connection is the one owner pairing this backend's defaults with its endpoint, but the messages resolve_connection raises under it still name the other service — and they fire on this backend's most likely first-run misconfigurations:

  • Missing credential: TypeSafe requires an API key: pass 'api_key' in guardrails.provider.config or set the OPENAI_API_KEY environment variable (typesafe/connection.py:145) — the service name says TypeSafe while the env var says OpenAI.
  • Blank model: model must be a non-empty string; TypeSafe returns the version it actually served (typesafe/connection.py:155) — for this backend model is required because no default ships (this module's own docstring says so), not because the endpoint echoes a served version, so the rationale clause is wrong here.

This is the same concern the provider_label seam exists for on the client ("a failure never reports the wrong operator's service"); construction-time messages just travel through the other seam. A service-label parameter on resolve_connection (defaulting to "TypeSafe"), passed as "OpenAI Decisions" here, would close it.


#: ``refusal`` is a value of an answer's ``type``, so it is a per-question outcome.
ANSWER_TYPE_REFUSAL = "refusal"
CATEGORY_REFUSAL = "refusal"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] The new category extends a set the shared contract still calls closed

CATEGORY_REFUSAL = "refusal" adds a fifth question-level category, but QuestionError's docstring in typesafe/client.py still says "category is one of missing, type, probability or label". Nothing downstream branches on category today (the gate reads only the message; the memory sides only answer presence), so this is contract hygiene rather than a bug — but the error taxonomy is one of the things this module claims to own, so either the enumeration in the shared class should gain refusal, or the constant should be declared next to its siblings in typesafe.client and re-exported so the closed set stays enumerated in one place.

@WillemJiang WillemJiang added this to the 2.2.0 milestone Oct 20, 2026
@WillemJiang
WillemJiang merged commit 1fd266e into bytedance:main Oct 20, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agents Agents, subagents, graph wiring, prompts, langgraph.json area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Add an OpenAI Decisions adapter to the existing Jev integration

4 participants