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
21 changes: 21 additions & 0 deletions docs/cross-platform-session-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ Workflow의 Agent Session 이벤트 계약도 `AgentSessionSnapshot`, `AgentSess

Workflow의 `GET /api/agentflow/agent-sessions/{id}/messages`에 맞춰 `AgentSessionMessagePage`, `AgentSessionMessageCursor`, `AgentSessionMessageConflictResponse`와 `apply_message_page`를 제공한다. 이 API는 저널에 연결된 완료 턴만 돌려주므로 메시지 sequence는 이벤트 사이에서 건너뛸 수 있고, `has_more=false`여도 `next_cursor`가 `snapshot_sequence`보다 작을 수 있다. SDK는 해당 세션의 마지막 턴 ID로 겹친 페이지를 확인하고 중복을 제외한다. cursor·턴 anchor·응답 구조가 어긋나면 `AgentSessionMessageGap`으로 중단한다. 입력·출력 텍스트가 없을 수 있으며 `content_complete`와 `source`가 그 상태와 일치해야 한다. 이 페이지를 전체 대화 snapshot으로 취급하거나 Agent Session 이벤트 cursor를 전진시키지 않는다. 인증·소유권 검사와 409 `SESSION_MESSAGE_LINK_INVALID` 처리 책임은 Gateway·Workflow 및 호출자에게 있다.

## 턴 상태 조회 (2026-10-15)

`AgentSessionTurnStatusClient`는 호출자가 제공한 인증된 JSON GET 함수로 고정 경로 `GET /api/agentflow/agent-sessions/{session_id}/turns/{turn_id}`를 한 번 조회한다. transport는 현재 Platform Session·DPoP, HTTPS origin, 응답 크기/시간 제한, HTTP 오류 처리를 담당한다. SDK는 인증 정보·base URL·환경변수를 새로 만들지 않는다.

```python
from xgen_sdk.agent_session import AgentSessionTurnStatusClient

# authenticated_json_get(path): 현재 세션/DPoP로 GET하고 JSON 객체를 반환하는 앱의 async 함수
client = AgentSessionTurnStatusClient(authenticated_json_get)
turn = await client.read(session_id, turn_id)
if turn.result_available:
# 결과 본문은 기존 messages 조회로 읽는다.
pass
```

응답은 정확한9개 필드의 frozen DTO다. 정규 UUID·요청 session/turn binding·status/progress 조합·sequence 순서·terminal 결과 증거를 재검사한다. `parse_agent_session_turn_status`로 이미 받은 dict/DTO도 다시 검증할 수 있다. 검증 실패는 원시 응답을 넣지 않는 `AgentSessionTurnStatusValidationError`이며, 호출자 transport의 HTTP/취소 예외는 그대로 전달한다. 자동 polling·재시도·reconcile·실행·lease/로그인 수명 연장은 없다.

`running_or_unknown`은 worker 생존을 증명하지 않으며 `cancelled/final`도 대상 process/stage 정리 완료를 증명하지 않는다. `awaiting_reconciliation`은 명시적인 기존 복구 경로가 필요한 상태다. SDK와 Frontend는 동일한 공개 JSON fixture로 유효·변조 응답의 해석을 대조한다. 패키지는 발행하지 않으며 상위 PR53은 Draft로 유지한다.

The caller owns authenticated, bounded JSON GET transport. The client fixes and validates the queryless path, revalidates the exact immutable response and its requested identity, and performs no retry, execution or lease extension. A terminal journal projection is separate from target cleanup. SDK and Frontend share public fixtures; publication and full ACTIVE acceptance remain separate gates.

나머지 항목은 서버 계약이 확정된 뒤 하위 브랜치와 별도 PR로 구현한다.

## Canonical 첨부 참조 계약 (2026-10-06)
Expand Down
12 changes: 12 additions & 0 deletions src/xgen_sdk/agent_session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@
SubmitAgentSessionTurn,
parse_turn_submission,
)
from .turn_status import (
AgentSessionTurnStatus,
AgentSessionTurnStatusClient,
AgentSessionTurnStatusGetTransport,
AgentSessionTurnStatusValidationError,
parse_agent_session_turn_status,
)

__all__ = [
"AGENT_ATTACHMENT_MAX_BYTES",
Expand All @@ -76,6 +83,10 @@
"AgentSessionReplayGap",
"AgentSessionSnapshot",
"AgentSessionTurnSummary",
"AgentSessionTurnStatus",
"AgentSessionTurnStatusClient",
"AgentSessionTurnStatusGetTransport",
"AgentSessionTurnStatusValidationError",
"AgentSessionTurnValidationError",
"AgentSessionCreated",
"CreateAgentSession",
Expand All @@ -93,5 +104,6 @@
"parse_attachment_references",
"parse_attachment_scope",
"parse_turn_submission",
"parse_agent_session_turn_status",
"prepare_attachment_references",
]
216 changes: 216 additions & 0 deletions src/xgen_sdk/agent_session/turn_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Strict read contract for one canonical Agent Session turn status.

The transport is supplied by the caller so the SDK never owns credentials or
authentication policy. A status is a point-in-time server projection: a
``running`` value does not prove that a worker is live, and ``cancelled`` does
not prove that target-side cleanup has completed.
"""

from __future__ import annotations

import re
from typing import Any, Literal, Protocol
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


_ERROR_MESSAGE = "Invalid canonical Agent Session turn status"
_UUID = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
_FIELDS = frozenset(
{
"turn_id",
"agent_session_id",
"status",
"progress",
"accepted_sequence",
"started_sequence",
"terminal_sequence",
"result_available",
"state_version",
}
)
_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"})


class AgentSessionTurnStatusValidationError(ValueError):
"""The supplied value is not a canonical turn-status response."""


def _canonical_uuid(value: Any) -> UUID:
if type(value) is UUID:
rendered = str(value)
elif type(value) is str:
rendered = value
else:
raise ValueError("identifier must be a UUID")
if not _UUID.fullmatch(rendered):
raise ValueError("identifier must be a canonical non-nil UUID")
return UUID(rendered)


class AgentSessionTurnStatus(BaseModel):
"""Frozen, exact DTO returned by the single-turn status endpoint."""

model_config = ConfigDict(
extra="forbid",
frozen=True,
strict=True,
hide_input_in_errors=True,
)

turn_id: UUID
agent_session_id: UUID
status: Literal["accepted", "running", "completed", "failed", "cancelled"]
progress: Literal[
"awaiting_claim",
"running_or_unknown",
"awaiting_reconciliation",
"final",
]
accepted_sequence: int = Field(ge=1, strict=True)
started_sequence: int | None = Field(ge=1, strict=True)
terminal_sequence: int | None = Field(ge=1, strict=True)
result_available: bool = Field(strict=True)
state_version: int = Field(ge=1, strict=True)

@field_validator("turn_id", "agent_session_id", mode="before")
@classmethod
def canonical_identifier(cls, value: Any) -> UUID:
return _canonical_uuid(value)

@model_validator(mode="after")
def coherent_projection(self) -> AgentSessionTurnStatus:
allowed_progress = {
"accepted": {"awaiting_claim", "awaiting_reconciliation"},
"running": {"running_or_unknown", "awaiting_reconciliation"},
"completed": {"final"},
"failed": {"final"},
"cancelled": {"final"},
}
if self.progress not in allowed_progress[self.status]:
raise ValueError("status and progress are inconsistent")

if self.status == "accepted" and self.started_sequence is not None:
raise ValueError("an accepted turn cannot have a start sequence")
if (
self.started_sequence is not None
and self.started_sequence <= self.accepted_sequence
):
raise ValueError("start sequence must follow acceptance")
if self.terminal_sequence is not None:
if self.terminal_sequence <= self.accepted_sequence:
raise ValueError("terminal sequence must follow acceptance")
if (
self.started_sequence is not None
and self.terminal_sequence <= self.started_sequence
):
raise ValueError("terminal sequence must follow start")
if self.result_available and (
self.status not in _TERMINAL_STATUSES
or self.terminal_sequence is None
):
raise ValueError("available results require terminal evidence")
return self

@classmethod
def model_construct(
cls, _fields_set: set[str] | None = None, **values: Any
) -> AgentSessionTurnStatus:
if set(values) - _FIELDS or (
_fields_set is not None and set(_fields_set) - _FIELDS
):
raise AgentSessionTurnStatusValidationError(_ERROR_MESSAGE) from None
return super().model_construct(_fields_set=_fields_set, **values)


class AgentSessionTurnStatusGetTransport(Protocol):
"""Caller-owned authenticated JSON GET callback."""

async def __call__(self, path: str, /) -> object: ...


def _identifier(value: Any) -> UUID:
try:
return _canonical_uuid(value)
except Exception:
pass
raise AgentSessionTurnStatusValidationError(_ERROR_MESSAGE) from None


def _model_input(value: Any) -> dict[str, Any]:
if type(value) is dict:
if set(value) != _FIELDS:
raise ValueError("response fields are not exact")
return {field: value[field] for field in _FIELDS}
if type(value) is not AgentSessionTurnStatus:
raise ValueError("unexpected turn-status model type")
if set(value.__dict__) != _FIELDS:
raise ValueError("turn-status model fields are not exact")
extra = getattr(value, "__pydantic_extra__", None)
if extra not in (None, {}):
raise ValueError("turn-status model has extra fields")
fields_set = getattr(value, "__pydantic_fields_set__", set())
if not isinstance(fields_set, set) or not fields_set <= _FIELDS:
raise ValueError("turn-status model field set is invalid")
return {field: value.__dict__[field] for field in _FIELDS}


def parse_agent_session_turn_status(
value: Any,
*,
expected_session_id: str | UUID,
expected_turn_id: str | UUID,
) -> AgentSessionTurnStatus:
"""Fully revalidate an exact response and bind it to the requested IDs."""
expected_session = _identifier(expected_session_id)
expected_turn = _identifier(expected_turn_id)
try:
parsed = AgentSessionTurnStatus.model_validate(_model_input(value), strict=True)
if (
parsed.agent_session_id != expected_session
or parsed.turn_id != expected_turn
):
raise ValueError("response identity does not match the request")
return parsed
except Exception:
pass
raise AgentSessionTurnStatusValidationError(_ERROR_MESSAGE) from None


class AgentSessionTurnStatusClient:
"""Read one turn through a caller-authenticated, fixed-path GET callback."""

__slots__ = ("_get",)

def __init__(self, get: AgentSessionTurnStatusGetTransport) -> None:
if not callable(get):
raise TypeError("get transport must be callable")
self._get = get

async def read(
self, session_id: str | UUID, turn_id: str | UUID
) -> AgentSessionTurnStatus:
canonical_session = _identifier(session_id)
canonical_turn = _identifier(turn_id)
path = (
f"/api/agentflow/agent-sessions/{canonical_session}"
f"/turns/{canonical_turn}"
)
value = await self._get(path)
return parse_agent_session_turn_status(
value,
expected_session_id=canonical_session,
expected_turn_id=canonical_turn,
)


__all__ = [
"AgentSessionTurnStatus",
"AgentSessionTurnStatusClient",
"AgentSessionTurnStatusGetTransport",
"AgentSessionTurnStatusValidationError",
"parse_agent_session_turn_status",
]
Loading
Loading