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
7 changes: 0 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,8 @@ Publish conversations from your agent, and Reflexio closes the self-improvement
| Tool | Description |
| --- | --- |
| [uv](https://docs.astral.sh/uv/getting-started/installation/) | Python package manager |
| Python's linked SQLite runtime | >= 3.35.0 (required for local SQLite storage; not the standalone `sqlite3` CLI) |
| [Node.js](https://nodejs.org/) >= 18 | Frontend runtime |

Check the SQLite runtime linked to Python with:

```shell
uv run python -c "import sqlite3; print(sqlite3.sqlite_version_info)"
```

<p align="center">
<img src="docs/images/demo.gif" width="800px" alt="Reflexio Demo">
</p>
Expand Down
10 changes: 5 additions & 5 deletions docs/lib/methods/requests-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const requestSessionMethods: MethodDef[] = [
displayName: "Mark Session Outcome",
group: "requests-sessions",
description:
"Record the lifetime-durable first terminal outcome for an existing session. Idempotency requires unchanged payload, governance context, and finalized trajectory/session context; changed values conflict. User and source are derived from the first request.",
"Record the lifetime-durable first success or failure marker for an existing session. User and source are derived from the first request.",
httpMethod: "POST",
endpoint: "/api/session_outcome",
requestStyle: "json_body",
Expand All @@ -17,8 +17,8 @@ export const requestSessionMethods: MethodDef[] = [
name: "outcome",
type: "enum",
required: true,
description: "Terminal outcome: success, failure, or unknown",
enumValues: ["success", "failure", "unknown"],
description: "Terminal outcome",
enumValues: ["success", "failure"],
},
{ name: "occurred_at", type: "number", required: true, description: "Unix epoch seconds when the outcome occurred" },
{ name: "label", type: "string", required: false, description: "Optional label, at most 128 characters" },
Expand All @@ -43,8 +43,8 @@ export const requestSessionMethods: MethodDef[] = [
name: "outcome",
type: "enum",
required: false,
description: "Exact outcome filter: success, failure, or unknown",
enumValues: ["success", "failure", "unknown"],
description: "Exact outcome filter",
enumValues: ["success", "failure"],
},
{ name: "label", type: "string", required: false, description: "Exact label filter" },
{ name: "start_time", type: "number", required: false, description: "Inclusive minimum event time" },
Expand Down
15 changes: 4 additions & 11 deletions reflexio/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,20 +1112,13 @@ def mark_session_outcome(
value: float | None = None,
metadata: dict[str, Any] | None = None,
) -> SetSessionOutcomeResponse:
"""Record the immutable first outcome for a published session.
"""Record the first terminal outcome for a published session.

The session must already contain at least one published request. Reflexio
derives both ``user_id`` and ``source`` from the earliest request ordered
by ``(created_at, request_id)``. New canonical rows bind the outcome to
the server-owned outcome contract and canonical finalized trajectory. An
exact canonical retry must match the payload, contract, and trajectory;
otherwise it is rejected with ``reason="conflicting_finalization"``.
Rolling-upgrade rows with all four identity fields null compare the
caller payload and any available server-derived session context, but
cannot compare absent contract or trajectory digests. An accepted retry
preserves all four null identity fields and returns ``success=True`` and
``recorded=False``. Sessions may report ``success``, ``failure``, or
``unknown`` and are not required to report an outcome.
by ``(created_at, request_id)``. Only the first outcome is recorded;
retries return ``success=True`` and ``recorded=False``. Sessions are not
required to report an outcome.
"""
request = SetSessionOutcomeRequest(
session_id=session_id,
Expand Down
81 changes: 40 additions & 41 deletions reflexio/lib/_session_outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,44 +53,51 @@ def mark_session_outcome(
try:
for _attempt in range(3):
context = storage.get_session_outcome_context(request.session_id)
if not context.existing:
if context.user_id is None or context.first_request_at is None:
return SetSessionOutcomeResponse(
success=False,
reason=SessionOutcomeFailureReason.UNKNOWN_SESSION,
message="Session has no published requests",
)
if context.user_contract_violation:
logger.warning(
"Session outcome contract violation: multiple users for session %s",
sanitise_for_log(request.session_id),
)
if context.source_contract_violation:
logger.warning(
"Session outcome contract violation: multiple sources for session %s",
sanitise_for_log(request.session_id),
)
if request.occurred_at < context.first_request_at:
if context.existing:
return SetSessionOutcomeResponse(
success=True,
recorded=False,
user_id=context.user_id,
source=context.source,
message="Outcome already exists",
)
if context.user_id is None or context.first_request_at is None:
return SetSessionOutcomeResponse(
success=False,
reason=SessionOutcomeFailureReason.UNKNOWN_SESSION,
message="Session has no published requests",
)
if context.user_contract_violation:
logger.warning(
"Session outcome contract violation: multiple users for session %s",
sanitise_for_log(request.session_id),
)
if context.source_contract_violation:
logger.warning(
"Session outcome contract violation: multiple sources for session %s",
sanitise_for_log(request.session_id),
)
if request.occurred_at < context.first_request_at:
return SetSessionOutcomeResponse(
success=False,
reason=SessionOutcomeFailureReason.OCCURRED_BEFORE_SESSION,
message="Outcome occurred before the session began",
user_id=context.user_id,
source=context.source,
)
provider = get_service(SESSION_OUTCOME_ACCEPTANCE)
if provider is not None:
reason = provider(
self.org_id, request, received_at, context.user_id
)
if reason is not None:
return SetSessionOutcomeResponse(
success=False,
reason=SessionOutcomeFailureReason.OCCURRED_BEFORE_SESSION,
message="Outcome occurred before the session began",
reason=reason,
message="Outcome was not accepted",
user_id=context.user_id,
source=context.source,
)
provider = get_service(SESSION_OUTCOME_ACCEPTANCE)
if provider is not None:
reason = provider(
self.org_id, request, received_at, context.user_id
)
if reason is not None:
return SetSessionOutcomeResponse(
success=False,
reason=reason,
message="Outcome was not accepted",
user_id=context.user_id,
source=context.source,
)
result = storage.record_session_outcome(
request,
created_at=received_at,
Expand All @@ -105,10 +112,6 @@ def mark_session_outcome(
message="Outcome was not recorded",
user_id=result.user_id,
source=result.source,
outcome_id=result.outcome_id,
outcome_revision=result.outcome_revision,
outcome_contract_digest=result.outcome_contract_digest,
finalized_trajectory_digest=result.finalized_trajectory_digest,
)
return SetSessionOutcomeResponse(
success=True,
Expand All @@ -120,10 +123,6 @@ def mark_session_outcome(
if result.recorded
else "Outcome already exists"
),
outcome_id=result.outcome_id,
outcome_revision=result.outcome_revision,
outcome_contract_digest=result.outcome_contract_digest,
finalized_trajectory_digest=result.finalized_trajectory_digest,
)
except Exception:
logger.exception("Failed to record session outcome")
Expand Down
62 changes: 2 additions & 60 deletions reflexio/models/api_schema/domain/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,8 +823,6 @@ class DeleteSessionResponse(BaseModel):


class SessionOutcomeRecord(BaseModel):
outcome_id: NonEmptyStr | None = None
outcome_revision: int | None = Field(default=None, ge=1)
user_id: str
session_id: NonEmptyStr
outcome: SessionOutcomeKind
Expand All @@ -833,35 +831,8 @@ class SessionOutcomeRecord(BaseModel):
label: str | None = Field(default=None, max_length=128)
value: float | None = Field(default=None, allow_inf_nan=False)
metadata: dict[str, Any] | None = None
outcome_contract_digest: Sha256Digest | None = None
finalized_trajectory_digest: Sha256Digest | None = None
created_at: int = Field(ge=0)

@field_validator("outcome_contract_digest", "finalized_trajectory_digest")
@classmethod
def validate_sha256_digest(cls, value: str | None) -> str | None:
if value is not None and (
len(value) != 64 or any(char not in "0123456789abcdef" for char in value)
):
raise ValueError("outcome identity digests must be lowercase SHA-256 hex")
return value

@model_validator(mode="after")
def validate_identity_shape(self) -> Self:
identity = (
self.outcome_id,
self.outcome_revision,
self.outcome_contract_digest,
self.finalized_trajectory_digest,
)
if any(value is None for value in identity) and not all(
value is None for value in identity
):
raise ValueError(
"outcome identity fields must be all populated or all null"
)
return self


class SetSessionOutcomeRequest(CapturesUnknownFields):
session_id: NonEmptyStr
Expand Down Expand Up @@ -907,35 +878,6 @@ class SetSessionOutcomeResponse(BaseModel):
message: str = ""
user_id: str | None = None
source: str | None = None
outcome_id: NonEmptyStr | None = None
outcome_revision: int | None = Field(default=None, ge=1)
outcome_contract_digest: Sha256Digest | None = None
finalized_trajectory_digest: Sha256Digest | None = None

@field_validator("outcome_contract_digest", "finalized_trajectory_digest")
@classmethod
def validate_sha256_digest(cls, value: str | None) -> str | None:
if value is not None and (
len(value) != 64 or any(char not in "0123456789abcdef" for char in value)
):
raise ValueError("outcome identity digests must be lowercase SHA-256 hex")
return value

@model_validator(mode="after")
def validate_identity_shape(self) -> Self:
identity = (
self.outcome_id,
self.outcome_revision,
self.outcome_contract_digest,
self.finalized_trajectory_digest,
)
if any(value is None for value in identity) and not all(
value is None for value in identity
):
raise ValueError(
"outcome identity fields must be all populated or all null"
)
return self


class GetSessionOutcomesRequest(CapturesUnknownFields):
Expand Down Expand Up @@ -1012,8 +954,8 @@ class DeleteUserPlaybooksByIdsRequest(BaseModel):
user_playbook_ids: list[int] = Field(min_length=1, max_length=10_000)


# Clear all data scoped to a single user_id (session outcomes, interactions,
# requests, user playbooks, profiles). Used by paired-protocol harnesses to
# Clear all data scoped to a single user_id (interactions, requests, session
# outcomes, user playbooks, profiles). Used by paired-protocol harnesses (e.g. SWE-bench) to
# isolate per-task data on a shared storage backend without nuking sibling
# tasks' rows. Intentionally does NOT touch agent_playbooks — they are the
# cross-project rollup of skills and have no user_id column.
Expand Down
2 changes: 0 additions & 2 deletions reflexio/models/api_schema/domain/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,9 @@
class SessionOutcomeKind(StrEnum):
SUCCESS = "success"
FAILURE = "failure"
UNKNOWN = "unknown"


class SessionOutcomeFailureReason(StrEnum):
CONFLICTING_FINALIZATION = "conflicting_finalization"
UNKNOWN_SESSION = "unknown_session"
OCCURRED_BEFORE_SESSION = "occurred_before_session"
OCCURRED_IN_FUTURE = "occurred_in_future"
Expand Down
Loading