diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b871e..a0e3750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d3cd372..6c937de 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/api/reference.md b/docs/api/reference.md index 77b6ec1..6b21f93 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -11387,10 +11387,6 @@ class DecisionAnswer(BaseModel) #### score - - -#### refusal - ## DecisionContext @@ -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. #### mode - - -#### policy\_version - - - -#### token\_estimator - - - -#### estimated\_input\_tokens - - - -#### max\_input\_tokens - - - -#### snapshot\_at - #### anchor\_event\_id @@ -11450,18 +11426,6 @@ running or failed records. Unknown fields remain accessible as model extras. #### included\_event\_count - - -#### scan\_limited - - - -#### truncated - - - -#### truncation\_reasons - ## DecisionError @@ -11520,10 +11484,6 @@ A stored evaluation. Retrieval and listing also expose running/failed records. #### context - - -#### usage - #### error diff --git a/src/qca/forward/types/decision.py b/src/qca/forward/types/decision.py index 9ac115e..0638a75 100644 --- a/src/qca/forward/types/decision.py +++ b/src/qca/forward/types/decision.py @@ -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 @@ -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): @@ -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 diff --git a/tests/fixtures/decisions.json b/tests/fixtures/decisions.json index 30036ba..04fdd86 100644 --- a/tests/fixtures/decisions.json +++ b/tests/fixtures/decisions.json @@ -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, @@ -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" diff --git a/tests/integration/test_decisions.py b/tests/integration/test_decisions.py index 4237f8c..5c9f686 100644 --- a/tests/integration/test_decisions.py +++ b/tests/integration/test_decisions.py @@ -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 diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 3d550d8..a64e934 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -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 @@ -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("")) @@ -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 = [] @@ -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())