Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ existing `0.1.0` release; earlier development prereleases are not listed.

- Clarify that the Forward Decision API is in Beta and its behavior may change in future updates.

### Breaking changes

- Align Beta Forward Decision responses with the updated API: remove `usage`, the `refusal` answer type/field, and internal context fields (`policy_version`, `token_estimator`, `estimated_input_tokens`, `max_input_tokens`, `snapshot_at`, `scan_limited`, `truncated`, `truncation_reasons`).

### Migration

- Remove reads of the deleted fields and handle `choice` / `score` answers. Use `context.mode` and optional Session event IDs/counts for context information; check `status` and `error` on stored records for failures.

### Fixed

- Accept Decision responses without `usage` when strict response validation is enabled.

## [0.10.0]

### Added
Expand Down
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,11 +644,9 @@ For scores, use `{"type": "score", "instructions": "Rate risk", "levels":
[{"label": "low", "score": 0}, {"label": "high", "score": 10}]}`.
`AsyncForward` offers the same methods with `await` and asynchronous pagination.

Session context is selected by the server. Inspect `context.truncated`,
`context.truncation_reasons`, and the input-token estimate to understand the
selected history. Explicit state and questions are never shortened; oversized
input returns HTTP 422. Context estimates are separate from `usage`, which can
be null. Answers can also have type `refusal`.
Session context is selected by the server. `context.mode` identifies the input
mode; Session results may include event IDs and counts describing the selected
history. Answers have type `choice` or `score`. Oversized input returns HTTP 422.

Reuse an idempotency key only for identical request bytes. A changed request or
a still-running request with that key returns HTTP 409; the SDK does not retry
Expand Down
44 changes: 2 additions & 42 deletions docs/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -11387,10 +11387,6 @@ class DecisionAnswer(BaseModel)

#### score

<a id="qca.forward.types.decision.DecisionAnswer.refusal"></a>

#### refusal

<a id="qca.forward.types.decision.DecisionContext"></a>

## DecisionContext
Expand All @@ -11401,35 +11397,15 @@ class DecisionContext(BaseModel)

[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py)

Server-selected input metadata. Estimates are not billed model usage.
Input mode and the Session events used for evaluation.

Session-only fields and preparation metadata may be absent, especially on
Session-only fields may be absent, especially on
running or failed records. Unknown fields remain accessible as model extras.

<a id="qca.forward.types.decision.DecisionContext.mode"></a>

#### mode

<a id="qca.forward.types.decision.DecisionContext.policy_version"></a>

#### policy\_version

<a id="qca.forward.types.decision.DecisionContext.token_estimator"></a>

#### token\_estimator

<a id="qca.forward.types.decision.DecisionContext.estimated_input_tokens"></a>

#### estimated\_input\_tokens

<a id="qca.forward.types.decision.DecisionContext.max_input_tokens"></a>

#### max\_input\_tokens

<a id="qca.forward.types.decision.DecisionContext.snapshot_at"></a>

#### snapshot\_at

<a id="qca.forward.types.decision.DecisionContext.anchor_event_id"></a>

#### anchor\_event\_id
Expand All @@ -11450,18 +11426,6 @@ running or failed records. Unknown fields remain accessible as model extras.

#### included\_event\_count

<a id="qca.forward.types.decision.DecisionContext.scan_limited"></a>

#### scan\_limited

<a id="qca.forward.types.decision.DecisionContext.truncated"></a>

#### truncated

<a id="qca.forward.types.decision.DecisionContext.truncation_reasons"></a>

#### truncation\_reasons

<a id="qca.forward.types.decision.DecisionError"></a>

## DecisionError
Expand Down Expand Up @@ -11520,10 +11484,6 @@ A stored evaluation. Retrieval and listing also expose running/failed records.

#### context

<a id="qca.forward.types.decision.Decision.usage"></a>

#### usage

<a id="qca.forward.types.decision.Decision.error"></a>

#### error
Expand Down
18 changes: 4 additions & 14 deletions src/qca/forward/types/decision.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import datetime
from typing import Any, Literal
from typing import Literal

from qca.common._models import BaseModel

Expand All @@ -26,34 +26,25 @@ class DecisionQuestion(BaseModel):


class DecisionAnswer(BaseModel):
type: Literal["choice", "score", "refusal"]
type: Literal["choice", "score"]
name: str | None = None
value: str | bool | None = None
score: float | None = None
refusal: str | None = None


class DecisionContext(BaseModel):
"""Server-selected input metadata. Estimates are not billed model usage.
"""Input mode and the Session events used for evaluation.

Session-only fields and preparation metadata may be absent, especially on
Session-only fields may be absent, especially on
running or failed records. Unknown fields remain accessible as model extras.
"""

mode: Literal["state", "session"]
policy_version: str | None = None
token_estimator: str | None = None
estimated_input_tokens: int | None = None
max_input_tokens: int | None = None
snapshot_at: datetime | None = None
anchor_event_id: str | None = None
first_event_id: str | None = None
last_event_id: str | None = None
included_segment_count: int | None = None
included_event_count: int | None = None
scan_limited: bool | None = None
truncated: bool | None = None
truncation_reasons: list[str] | None = None


class DecisionError(BaseModel):
Expand All @@ -71,7 +62,6 @@ class Decision(BaseModel):
questions: list[DecisionQuestion]
answers: list[DecisionAnswer]
context: DecisionContext
usage: dict[str, Any] | None
error: DecisionError | None
created_at: datetime
completed_at: datetime | None
9 changes: 2 additions & 7 deletions tests/fixtures/decisions.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"source": "cloud-agents-forward-service@8b9474c6: internal/router/router.go, internal/handler/decision/handler.go, internal/model/dto/decision.go, internal/service/decision/{validation,service,context}.go",
"source": "cloud-agents-forward-service@102f6f64: internal/router/router.go, internal/handler/decision/handler.go, internal/model/dto/decision.go, internal/service/decision/{validation,service,context}.go",
"request": {
"state": {
"resolved": false,
Expand Down Expand Up @@ -92,13 +92,8 @@
}
],
"context": {
"mode": "state",
"policy_version": "decision-context-v3",
"token_estimator": "caw-heuristic-v1",
"estimated_input_tokens": 234,
"max_input_tokens": 30000
"mode": "state"
},
"usage": null,
"error": null,
"created_at": "2026-10-23T05:00:00.123456Z",
"completed_at": "2026-10-23T05:00:01.123456Z"
Expand Down
16 changes: 15 additions & 1 deletion tests/integration/test_decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,21 @@ def test_decisions(live_example, mode):
decision = client.decisions.create(**params, timeout=context.remaining())
assert decision.id and decision.status == "completed"
assert decision.context.mode == mode and len(decision.answers) == 2
assert 0 < decision.context.estimated_input_tokens <= decision.context.max_input_tokens
assert "usage" not in decision.to_dict()
assert (
not {
"truncated",
"max_input_tokens",
"policy_version",
"estimated_input_tokens",
"token_estimator",
"truncation_reasons",
"scan_limited",
"snapshot_at",
}
& decision.context.to_dict().keys()
)
assert all(answer.type in ("choice", "score") for answer in decision.answers)
if mode == "state":
assert decision.session_id is None
assert decision.answers[0].value is False and decision.answers[1].score == 0
Expand Down
55 changes: 45 additions & 10 deletions tests/test_decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,13 @@ def handle(request):
result["session_id"] = "sess_own"
result["context"].update(
mode="session",
snapshot_at="2026-10-26T00:00:00.123456789Z",
truncated=True,
truncation_reasons=["segment_budget"],
anchor_event_id="evt_last",
first_event_id="evt_first",
last_event_id="evt_last",
included_event_count=14,
included_segment_count=12,
scan_limited=False,
future_context=True,
)
result["answers"] = [{"type": "refusal", "name": "private", "refusal": "Cannot assess"}]
else:
assert request.url.raw_path.endswith(b"/decisions/opaque%20%2F%3F%25%23")
assert not request.content
Expand All @@ -68,15 +67,18 @@ def handle(request):
assert isinstance(result, Decision)
assert result.answers[0].value is False and result.answers[1].score == 0
assert "value" not in result.answers[1].model_fields_set
assert result.session_id is None and result.usage is None and result.error is None
assert result.session_id is None and result.error is None
assert "usage" not in result.to_dict()
assert result.context.to_dict() == {"mode": "state"}
assert result.created_at.microsecond == 123456
assert result._request_id == "req_decision" and result.model_extra["future_field"] == 1
result = await resolve(
client.decisions.create(session_id="sess_own", questions=FIXTURE["request"]["questions"])
)
assert result.context.truncated and result.context.included_segment_count == 12
assert result.context.scan_limited is False and result.context.model_extra["future_context"] is True
assert result.answers[0].refusal == "Cannot assess" and "value" not in result.answers[0].model_fields_set
assert result.context.included_segment_count == 12 and result.context.included_event_count == 14
assert result.context.anchor_event_id == result.context.last_event_id == "evt_last"
assert result.context.first_event_id == "evt_first" and result.context.model_extra["future_context"] is True
assert result.answers[0].value is False and result.answers[1].score == 0
assert (await resolve(client.decisions.retrieve("opaque /?%#"))).id == "dec_contract"
with pytest.raises(ValueError):
await resolve(client.decisions.retrieve(""))
Expand Down Expand Up @@ -166,7 +168,7 @@ def handle(request):


@pytest.mark.parametrize("async_", [False, True])
@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 422, 429, 504])
@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 422, 429, 502, 504])
async def test_decision_error_keeps_correlation_and_server_validation(async_, status):
calls = []

Expand All @@ -192,3 +194,36 @@ def handle(request):
assert len(calls) == 1
finally:
await resolve(client.close())


@pytest.mark.parametrize("async_", [False, True])
@pytest.mark.parametrize("status", ["running", "failed"])
async def test_decision_incomplete_records_without_internal_fields(async_, status):
record = copy.deepcopy(FIXTURE["response"])
record.update(status=status, session_id="sess_own", answers=[], context={"mode": "session"})
if status == "running":
record["completed_at"] = None
else:
record["error"] = {"code": "decision_invalid_upstream_response", "message": "Invalid JEV response."}

def handle(request):
if request.url.path.endswith("/dec_contract"):
return httpx.Response(200, json=record)
return httpx.Response(
200, json={"data": [record], "has_more": False, "first_id": record["id"], "last_id": record["id"]}
)

client = client_for(async_, handle, pat="token", _strict_response_validation=True)
try:
result = await resolve(client.decisions.retrieve("dec_contract"))
page = await resolve(client.decisions.list(session_id="sess_own"))
assert result.to_dict() == page.data[0].to_dict()
assert result.status == status and result.answers == []
assert result.context.to_dict() == {"mode": "session"}
assert "usage" not in result.to_dict()
if status == "running":
assert result.completed_at is None and result.error is None
else:
assert result.error.code == "decision_invalid_upstream_response"
finally:
await resolve(client.close())
Loading