Repository navigation
feat(decisions): add an OpenAI Decisions backend for the decision slots - #7127
Conversation
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.
| #: 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 |
There was a problem hiding this comment.
[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() # failsI 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
left a comment
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
[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 backendmodelis 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" |
There was a problem hiding this comment.
[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.
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. Apredicatequestion has no criteria field, so the true/false rubric folds intoinstructions; a refusal is an answertype, so it becomes a per-questionQuestionErrorrather than an HTTP failure. Answers are validated into the existingNoulAnswer/ChoiceAnswer/AnswerSetshapes, which is what lets every consumer keep its own threshold and failure direction untouched.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 (inputtext instead of astateobject). Thresholds, rubrics, caches, per-question fallback,request_failedreporting, sharing identity and audit stay in the base classes, because two copies of a security boundary drift.deerflow.typesafegains the two seams a dialect actually needs:provider_label, so a request-level message names the service the operator configured, andTypeSafeConnection.endpoint_path, so the endpoint path belongs to the backend that resolves the connection (https://api.openai.com+/v1/systemonecan 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_connectionpairs 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:modeldeliberately 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 inbackend/docs/GUARDRAILS.mdandconfig.example.yaml.Verification
backend/tests/test_openai_decisions.py(38 tests, no network or credentials, running throughhttpx.MockTransportthe way the Jev suites do like the Jev suites) covering both mapping directions and each consumer's direction: the gate denies at or abovethresholdand maps a refusal toOpenAIDecisionsGuardrailError(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.modelpresent vs. absent; non-200 / non-array / oversized / compressed bodies all stay request-level failures.instructions→ 1 red;modelrequired 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.test_typesafe_client,test_typesafe_config,test_typesafe_guardrail_provider,test_memory_prescreen,test_app_config_reloadandtests/blocking_io/test_typesafe_guardrail_provider→ 339 passed with the seams in place and the new suite on top.feat/openai-decisions-backend@bb00a68, baseorigin/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
scorequestions and thelevels/confidencefields: no DeerFlow consumer asks them today, and the sharedQuestiontype has no such shape./v1/decisionsrequest/response shape and every test is offline.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.