feat(session): add session domain models and contracts
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / security (push) Waiting to run
CI / quality (push) Waiting to run
CI / unit_tests (push) Waiting to run
CI / integration_tests (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / build (push) Waiting to run
CI / docker (push) Blocked by required conditions
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 27s
CI / security (pull_request) Successful in 21s
CI / quality (pull_request) Successful in 15s
CI / integration_tests (pull_request) Successful in 4m14s
CI / build (pull_request) Successful in 15s
CI / unit_tests (pull_request) Successful in 8m46s
CI / coverage (pull_request) Successful in 6m43s
CI / docker (pull_request) Successful in 38s

This commit is contained in:
Jeff
2026-02-13 22:15:46 +00:00
parent da8e5f716d
commit eb1638a84e
7 changed files with 1788 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
"""ASV benchmarks for Session domain model validation and serialization.
Measures the performance of:
- Session model construction (Pydantic validation)
- SessionMessage construction
- Session.append_message() auto-sequencing
- Session.as_cli_dict() serialization
- Session.as_export_dict() serialization with checksum
- SessionTokenUsage construction
- Session.get_messages() with pagination
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionTokenUsage,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionTokenUsage,
)
from ulid import ULID
def _make_session(message_count: int = 0) -> Session:
"""Create a Session with optional messages for benchmarking."""
session = Session(session_id=str(ULID()))
for i in range(message_count):
session.append_message(MessageRole.USER, f"Benchmark message {i}")
return session
def _make_message() -> SessionMessage:
"""Create a fully-populated SessionMessage for benchmarking."""
return SessionMessage(
message_id=str(ULID()),
role=MessageRole.USER,
content="Benchmark message content",
sequence=0,
metadata={"key": "value"},
)
class SessionValidationSuite:
"""Benchmark Session model construction (Pydantic validation)."""
def time_session_construction(self) -> None:
"""Benchmark creating a minimal Session object."""
Session(session_id=str(ULID()))
def time_session_with_actor(self) -> None:
"""Benchmark creating a Session with actor_name."""
Session(session_id=str(ULID()), actor_name="local/orchestrator")
def time_message_construction(self) -> None:
"""Benchmark creating a SessionMessage."""
_make_message()
def time_tool_message_construction(self) -> None:
"""Benchmark creating a tool SessionMessage with tool_call_id."""
SessionMessage(
message_id=str(ULID()),
role=MessageRole.TOOL,
content="Tool result",
sequence=0,
tool_call_id="call_123",
)
def time_token_usage_construction(self) -> None:
"""Benchmark SessionTokenUsage construction."""
SessionTokenUsage(
input_tokens=1000,
output_tokens=500,
estimated_cost=0.05,
)
class SessionAppendSuite:
"""Benchmark Session.append_message() auto-sequencing."""
def setup(self) -> None:
"""Create a session for append benchmarks."""
self.session = _make_session()
def time_append_single_message(self) -> None:
"""Benchmark appending a single message."""
self.session.append_message(MessageRole.USER, "Benchmark message")
def time_append_10_messages(self) -> None:
"""Benchmark appending 10 messages sequentially."""
session = _make_session()
for i in range(10):
session.append_message(MessageRole.USER, f"Message {i}")
class SessionSerializationSuite:
"""Benchmark Session serialization methods."""
def setup(self) -> None:
"""Create a session with messages for serialization benchmarks."""
self.session = _make_session(message_count=10)
self.session.link_plan(str(ULID()))
self.large_session = _make_session(message_count=100)
def time_as_cli_dict(self) -> None:
"""Benchmark Session.as_cli_dict() ordered dict generation."""
self.session.as_cli_dict()
def time_as_export_dict(self) -> None:
"""Benchmark Session.as_export_dict() with checksum generation."""
self.session.as_export_dict()
def time_as_export_dict_large(self) -> None:
"""Benchmark export dict for a large session (100 messages)."""
self.large_session.as_export_dict()
def time_model_dump(self) -> None:
"""Benchmark Pydantic model_dump() serialization."""
self.session.model_dump()
def time_model_dump_json(self) -> None:
"""Benchmark Pydantic model_dump_json() JSON serialization."""
self.session.model_dump_json()
class SessionPaginationSuite:
"""Benchmark Session.get_messages() with pagination."""
def setup(self) -> None:
"""Create a session with many messages for pagination benchmarks."""
self.session = _make_session(message_count=100)
def time_get_all_messages(self) -> None:
"""Benchmark getting all messages (no limit)."""
self.session.get_messages()
def time_get_first_10(self) -> None:
"""Benchmark getting first 10 messages."""
self.session.get_messages(limit=10)
def time_get_with_offset(self) -> None:
"""Benchmark getting messages with offset."""
self.session.get_messages(limit=10, offset=50)
+105
View File
@@ -0,0 +1,105 @@
# Session Domain Model
A `Session` is a persistent conversation thread tied to an orchestrator actor.
It maintains message history across plans and serves as the user's
natural-language interface.
## MessageRole
| Value | Description |
|-------------|--------------------------------|
| `user` | Human input |
| `assistant` | AI/agent response |
| `system` | System-level instructions |
| `tool` | Tool invocation result |
## SessionMessage
Each message within a session:
| Field | Type | Default | Description |
|----------------|-------------------|-----------------|------------------------------------------|
| `message_id` | `str` | (required) | Unique ULID identifier |
| `role` | `MessageRole` | (required) | Role of the message sender |
| `content` | `str` | (required) | Message content (min 1 char, no whitespace-only) |
| `sequence` | `int` | (required) | Ordering index within the session (>= 0) |
| `timestamp` | `datetime` | `datetime.now()`| When the message was created |
| `metadata` | `dict[str, Any]` | `{}` | Optional metadata |
| `tool_call_id` | `str \| None` | `None` | Required when role is `tool` |
**Constraints**:
- `content` must not be whitespace-only
- `tool_call_id` is required when `role` is `tool`
## SessionTokenUsage
Accumulated token usage for a session:
| Field | Type | Default | Description |
|------------------|---------|---------|--------------------------------|
| `input_tokens` | `int` | `0` | Total input tokens consumed |
| `output_tokens` | `int` | `0` | Total output tokens generated |
| `estimated_cost` | `float` | `0.0` | Estimated total cost (USD) |
All fields must be >= 0.
## Session Fields
| Field | Type | Default | Description |
|--------------------|---------------------------|--------------------------|----------------------------------------|
| `session_id` | `str` | (required) | Unique ULID identifier |
| `actor_name` | `str \| None` | `None` | Namespaced actor reference |
| `namespace` | `str` | `"local"` | Namespace for ownership |
| `messages` | `list[SessionMessage]` | `[]` | Ordered messages |
| `linked_plan_ids` | `list[str]` | `[]` | ULID references to linked plans |
| `automation_level` | `str \| None` | `None` | Session-level automation override |
| `token_usage` | `SessionTokenUsage` | `SessionTokenUsage()` | Accumulated token usage |
| `created_at` | `datetime` | `datetime.now()` | Creation timestamp |
| `updated_at` | `datetime` | `datetime.now()` | Last modification timestamp |
| `metadata` | `dict[str, Any]` | `{}` | Arbitrary session metadata |
**Constraints**:
- `session_id` must match ULID format (`^[0-9A-HJKMNP-TV-Z]{26}$`)
- `actor_name` if provided must match `namespace/name` pattern
- `messages` must be ordered by `sequence`
## Properties
- `session.message_count` -- Number of messages
- `session.last_message` -- Most recent message (or None)
- `session.is_empty` -- True if no messages
## Methods
- `session.append_message(role, content, metadata=None, tool_call_id=None)` -- Append a message with auto-generated ULID and sequence
- `session.get_messages(limit=None, offset=0)` -- Paginated message retrieval
- `session.link_plan(plan_id)` -- Link a plan (deduplicated)
- `session.as_cli_dict()` -- Stable ordered dict for CLI rendering
- `session.as_export_dict()` -- JSON-serializable dict with checksum
## CLI Dict Output
The `as_cli_dict()` output matches `agents session show`:
- `session_id`, `actor_name`, `namespace`, `message_count`
- `created_at`, `updated_at`, `automation_level`
- `recent_messages` (last 5), `linked_plan_ids`
- `token_usage` (input/output/cost), `metadata`
## Export Dict
The `as_export_dict()` output includes:
- `schema_version`, `session_id`, `actor_name`, `namespace`
- `messages` (full history), `linked_plan_ids`
- `automation_level`, `token_usage`, `metadata`
- `created_at`, `updated_at`, `checksum` (SHA-256)
## Error Types
| Error | Base | Description |
|------------------------|-------------------------|------------------------------------|
| `SessionServiceError` | `Exception` | Base error for session operations |
| `SessionNotFoundError` | `SessionServiceError` | Session ID not found |
| `SessionExportError` | `SessionServiceError` | Export failures |
| `SessionImportError` | `SessionServiceError` | Import failures (schema, corrupt) |
+125
View File
@@ -0,0 +1,125 @@
# Session Service Contract
The `SessionService` is the abstract interface for session management in
CleverAgents. Implementations handle persistence, ULID generation, and
lifecycle management for `Session` objects.
## Interface
```python
class SessionService(ABC):
def create(self, actor_name: str | None = None) -> Session: ...
def get(self, session_id: str) -> Session: ...
def list(self) -> list[Session]: ...
def delete(self, session_id: str) -> None: ...
def append_message(
self,
session_id: str,
role: MessageRole,
content: str,
metadata: dict | None = None,
) -> SessionMessage: ...
def export_session(self, session_id: str) -> dict: ...
def import_session(self, data: dict) -> Session: ...
def update_token_usage(
self,
session_id: str,
input_tokens: int,
output_tokens: int,
cost: float,
) -> None: ...
```
## Methods
### create
Create a new session with a fresh ULID. Optionally bind to an actor.
| Parameter | Type | Default | Description |
|-------------|----------------|---------|-------------------------------|
| `actor_name` | `str \| None` | `None` | Namespaced actor to bind |
**Returns**: `Session`
### get
Retrieve a session by its ULID.
**Raises**: `SessionNotFoundError` if not found.
### list
Return all sessions ordered by creation time.
### delete
Remove a session by its ULID.
**Raises**: `SessionNotFoundError` if not found.
### append_message
Add a message to a session. The implementation generates a ULID for the
message and sets the sequence number.
| Parameter | Type | Default | Description |
|-------------|-------------------|---------|------------------------------|
| `session_id` | `str` | -- | Target session ULID |
| `role` | `MessageRole` | -- | Message role |
| `content` | `str` | -- | Message content |
| `metadata` | `dict \| None` | `None` | Optional metadata |
**Returns**: `SessionMessage`
**Raises**: `SessionNotFoundError` if session not found.
### export_session
Export a session as a JSON-serializable dict including a SHA-256 checksum
for integrity verification.
**Raises**: `SessionNotFoundError`, `SessionExportError`
### import_session
Import a session from an export dict. Validates schema version and
checksum integrity.
**Raises**: `SessionImportError` on schema mismatch or corrupt data.
### update_token_usage
Increment token usage counters for a session.
| Parameter | Type | Description |
|----------------|---------|--------------------------------|
| `session_id` | `str` | Target session ULID |
| `input_tokens` | `int` | Input tokens to add |
| `output_tokens` | `int` | Output tokens to add |
| `cost` | `float` | Estimated cost to add (USD) |
**Raises**: `SessionNotFoundError` if session not found.
## Error Hierarchy
```
Exception
-> SessionServiceError (base)
-> SessionNotFoundError (session ID not found)
-> SessionExportError (export failures)
-> SessionImportError (import failures)
```
## CLI Commands
The service backs the following CLI commands:
| Command | Service Method |
|------------------------|---------------------|
| `agents session create` | `create()` |
| `agents session list` | `list()` |
| `agents session show` | `get()` |
| `agents session delete` | `delete()` |
| `agents session export` | `export_session()` |
| `agents session import` | `import_session()` |
| `agents session tell` | `append_message()` |
+234
View File
@@ -0,0 +1,234 @@
Feature: Session Domain Model
As a developer
I want session domain models for conversation management
So that sessions can be created, tracked, and exported via CLI
# ---- Session Creation ----
Scenario: Create a session with valid ULID
When I create a session with a valid ULID
Then the session model should be created
And the session model session_id should be a valid ULID
Scenario: Create a session with actor name
When I create a session with actor name "local/orchestrator"
Then the session model should be created
And the session model actor_name should be "local/orchestrator"
Scenario: Create a session without actor name
When I create a session without actor name
Then the session model should be created
And the session model actor_name should be empty
Scenario: Session has default namespace local
When I create a session with a valid ULID
Then the session model namespace should be "local"
# ---- Session ID Validation ----
Scenario: Session rejects invalid ULID format
When I try to create a session with invalid ID "not-a-ulid"
Then a session model validation error should be raised
# ---- Actor Name Validation ----
Scenario: Session rejects invalid actor name format
When I try to create a session with invalid actor name "badformat"
Then a session model validation error should be raised
Scenario: Session accepts None actor name
When I create a session without actor name
Then the session model should be created
# ---- Message Append with Auto-sequencing ----
Scenario: Append a user message to session
Given a session with no messages
When I append a user message "Hello"
Then the session model should have 1 message
And the session last message content should be "Hello"
And the session last message role should be "user"
And the session last message sequence should be 0
Scenario: Append multiple messages with auto-sequencing
Given a session with no messages
When I append a user message "Hello"
And I append an assistant message "Hi there"
Then the session model should have 2 messages
And the session last message sequence should be 1
# ---- Message Role Validation ----
Scenario: Tool message requires tool_call_id
When I try to create a tool message without tool_call_id
Then a session model validation error should be raised
And the session model error should mention "tool_call_id"
Scenario: Tool message with tool_call_id is valid
When I create a tool message with tool_call_id "call_123"
Then the session message should be created
And the session message tool_call_id should be "call_123"
Scenario: User message does not require tool_call_id
When I create a user message "Hello"
Then the session message should be created
# ---- Content Validation ----
Scenario: Message rejects whitespace-only content
When I try to create a message with whitespace-only content
Then a session model validation error should be raised
And the session model error should mention "whitespace"
Scenario: Message rejects empty content
When I try to create a message with empty content
Then a session model validation error should be raised
# ---- Message Ordering ----
Scenario: Messages must be ordered by sequence
When I try to create a session with out-of-order messages
Then a session model validation error should be raised
And the session model error should mention "ordered by sequence"
# ---- Plan Linking ----
Scenario: Link a plan to session
Given a session with no messages
When I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
Then the session should have 1 linked plan
Scenario: Plan linking deduplicates
Given a session with no messages
When I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
And I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
Then the session should have 1 linked plan
Scenario: Link multiple plans
Given a session with no messages
When I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
And I link plan "01BX5ZZKBKACTAV9WEVGEMMVRY" to the session
Then the session should have 2 linked plans
# ---- Token Usage Tracking ----
Scenario: Default token usage is zero
When I create a session with a valid ULID
Then the session token usage input_tokens should be 0
And the session token usage output_tokens should be 0
And the session token usage estimated_cost should be 0.0
Scenario: Token usage with values
When I create a session with token usage 100 input 50 output 0.05 cost
Then the session token usage input_tokens should be 100
And the session token usage output_tokens should be 50
And the session token usage estimated_cost should be 0.05
Scenario: Token usage rejects negative values
When I try to create token usage with negative input tokens
Then a session model validation error should be raised
# ---- Export Dict ----
Scenario: Export dict contains checksum
Given a session with some messages
When I export the session
Then the export dict should have key "checksum"
And the export dict should have key "schema_version"
And the export dict should have key "messages"
And the export dict should have key "session_id"
Scenario: Export dict round-trip preserves data
Given a session with some messages
When I export the session
Then the export dict messages count should match session message count
# ---- CLI Dict ----
Scenario: CLI dict has required fields
Given a session with some messages
When I get the session CLI dict
Then the session cli dict should have key "session_id"
And the session cli dict should have key "message_count"
And the session cli dict should have key "created_at"
And the session cli dict should have key "updated_at"
And the session cli dict should have key "token_usage"
And the session cli dict should have key "namespace"
Scenario: CLI dict includes recent messages
Given a session with some messages
When I get the session CLI dict
Then the session cli dict should have key "recent_messages"
Scenario: CLI dict includes actor when set
When I create a session with actor name "local/orchestrator"
And I get the session CLI dict
Then the session cli dict should have key "actor_name"
Scenario: CLI dict includes automation level when set
Given a session with automation level "full_automation"
When I get the session CLI dict
Then the session cli dict should have key "automation_level"
# ---- Empty Session Properties ----
Scenario: Empty session has zero message count
Given a session with no messages
Then the session message count should be 0
And the session is_empty should be true
And the session last_message should be none
# ---- get_messages with limit/offset ----
Scenario: get_messages with no limit returns all
Given a session with 5 messages
When I get messages with no limit
Then I should get 5 messages
Scenario: get_messages with limit
Given a session with 5 messages
When I get messages with limit 3
Then I should get 3 messages
Scenario: get_messages with offset
Given a session with 5 messages
When I get messages with offset 2
Then I should get 3 messages
Scenario: get_messages with limit and offset
Given a session with 5 messages
When I get messages with limit 2 and offset 1
Then I should get 2 messages
# ---- Session Error Types ----
Scenario: SessionServiceError is base error
Then SessionServiceError should be an Exception subclass
Scenario: SessionNotFoundError inherits from SessionServiceError
Then SessionNotFoundError should be a SessionServiceError subclass
Scenario: SessionExportError inherits from SessionServiceError
Then SessionExportError should be a SessionServiceError subclass
Scenario: SessionImportError inherits from SessionServiceError
Then SessionImportError should be a SessionServiceError subclass
# ---- MessageRole enum ----
Scenario: MessageRole has expected values
Then the MessageRole enum should have values "user,assistant,system,tool"
# ---- Session metadata ----
Scenario: Session supports metadata
When I create a session with metadata
Then the session metadata should contain key "env"
# ---- Export dict linked plans ----
Scenario: Export dict includes linked plan IDs
Given a session with no messages
When I link plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the session
And I export the session
Then the export dict should have key "linked_plan_ids"
+611
View File
@@ -0,0 +1,611 @@
"""Step definitions for Session domain model tests."""
from typing import Any
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from ulid import ULID
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionExportError,
SessionImportError,
SessionMessage,
SessionNotFoundError,
SessionServiceError,
SessionTokenUsage,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_VALID_ULID = str(ULID())
def _make_session(**overrides: Any) -> Session:
"""Create a Session with sensible defaults, allowing overrides."""
defaults: dict[str, Any] = {
"session_id": str(ULID()),
}
defaults.update(overrides)
return Session(**defaults)
def _make_message(**overrides: Any) -> SessionMessage:
"""Create a SessionMessage with sensible defaults."""
defaults: dict[str, Any] = {
"message_id": str(ULID()),
"role": MessageRole.USER,
"content": "Test message",
"sequence": 0,
}
defaults.update(overrides)
return SessionMessage(**defaults)
# ---------------------------------------------------------------------------
# Session Creation Steps
# ---------------------------------------------------------------------------
@when("I create a session with a valid ULID")
def session_model_create_with_valid_ulid(context: Context) -> None:
"""Create a session with a valid ULID."""
context.session_model = _make_session()
context.session_model_error = None
@when('I create a session with actor name "{actor_name}"')
def session_model_create_with_actor(context: Context, actor_name: str) -> None:
"""Create a session with a specific actor name."""
context.session_model = _make_session(actor_name=actor_name)
context.session_model_error = None
@when("I create a session without actor name")
def session_model_create_without_actor(context: Context) -> None:
"""Create a session without an actor name."""
context.session_model = _make_session(actor_name=None)
context.session_model_error = None
# ---------------------------------------------------------------------------
# Session Validation Steps
# ---------------------------------------------------------------------------
@when('I try to create a session with invalid ID "{session_id}"')
def session_model_try_invalid_id(context: Context, session_id: str) -> None:
"""Attempt to create a session with an invalid ID."""
context.session_model_error = None
context.session_model = None
try:
context.session_model = Session(session_id=session_id)
except ValidationError as e:
context.session_model_error = e
@when('I try to create a session with invalid actor name "{actor_name}"')
def session_model_try_invalid_actor(context: Context, actor_name: str) -> None:
"""Attempt to create a session with an invalid actor name."""
context.session_model_error = None
context.session_model = None
try:
context.session_model = _make_session(actor_name=actor_name)
except ValidationError as e:
context.session_model_error = e
# ---------------------------------------------------------------------------
# Session Assertions
# ---------------------------------------------------------------------------
@then("the session model should be created")
def session_model_check_created(context: Context) -> None:
"""Verify the session was created."""
assert context.session_model is not None, "Session should be created"
@then("the session model session_id should be a valid ULID")
def session_model_check_ulid(context: Context) -> None:
"""Verify the session_id is a valid ULID."""
import re
pattern = r"^[0-9A-HJKMNP-TV-Z]{26}$"
assert re.match(pattern, context.session_model.session_id), (
f"session_id '{context.session_model.session_id}' is not a valid ULID"
)
@then('the session model actor_name should be "{expected}"')
def session_model_check_actor_name(context: Context, expected: str) -> None:
"""Check the session actor_name."""
assert context.session_model.actor_name == expected, (
f"Expected actor_name '{expected}', got '{context.session_model.actor_name}'"
)
@then("the session model actor_name should be empty")
def session_model_check_actor_name_empty(context: Context) -> None:
"""Check the session actor_name is None."""
assert context.session_model.actor_name is None, (
f"Expected actor_name to be None, got '{context.session_model.actor_name}'"
)
@then('the session model namespace should be "{expected}"')
def session_model_check_namespace(context: Context, expected: str) -> None:
"""Check the session namespace."""
assert context.session_model.namespace == expected, (
f"Expected namespace '{expected}', got '{context.session_model.namespace}'"
)
@then("a session model validation error should be raised")
def session_model_check_validation_error(context: Context) -> None:
"""Verify that a validation error was raised."""
assert context.session_model_error is not None, (
"Expected a validation error to be raised"
)
@then('the session model error should mention "{text}"')
def session_model_check_error_message(context: Context, text: str) -> None:
"""Check the error message contains expected text."""
error_str = str(context.session_model_error)
assert text.lower() in error_str.lower(), (
f"Expected error to mention '{text}', got: {error_str}"
)
# ---------------------------------------------------------------------------
# Message Steps
# ---------------------------------------------------------------------------
@given("a session with no messages")
def session_model_given_empty_session(context: Context) -> None:
"""Create a session with no messages."""
context.session_model = _make_session()
context.session_model_error = None
@when('I append a user message "{content}"')
def session_model_append_user_message(context: Context, content: str) -> None:
"""Append a user message to the session."""
context.session_model.append_message(MessageRole.USER, content)
@when('I append an assistant message "{content}"')
def session_model_append_assistant_message(context: Context, content: str) -> None:
"""Append an assistant message to the session."""
context.session_model.append_message(MessageRole.ASSISTANT, content)
@then("the session model should have {count:d} message")
def session_model_check_message_count_singular(context: Context, count: int) -> None:
"""Check message count (singular)."""
actual = context.session_model.message_count
assert actual == count, f"Expected {count} message(s), got {actual}"
@then("the session model should have {count:d} messages")
def session_model_check_message_count_plural(context: Context, count: int) -> None:
"""Check message count (plural)."""
actual = context.session_model.message_count
assert actual == count, f"Expected {count} message(s), got {actual}"
@then('the session last message content should be "{expected}"')
def session_model_check_last_message_content(context: Context, expected: str) -> None:
"""Check the last message content."""
last = context.session_model.last_message
assert last is not None, "Expected a last message"
assert last.content == expected, (
f"Expected content '{expected}', got '{last.content}'"
)
@then('the session last message role should be "{expected}"')
def session_model_check_last_message_role(context: Context, expected: str) -> None:
"""Check the last message role."""
last = context.session_model.last_message
assert last is not None, "Expected a last message"
assert last.role.value == expected, (
f"Expected role '{expected}', got '{last.role.value}'"
)
@then("the session last message sequence should be {expected:d}")
def session_model_check_last_message_sequence(context: Context, expected: int) -> None:
"""Check the last message sequence."""
last = context.session_model.last_message
assert last is not None, "Expected a last message"
assert last.sequence == expected, (
f"Expected sequence {expected}, got {last.sequence}"
)
# ---------------------------------------------------------------------------
# Tool Message Steps
# ---------------------------------------------------------------------------
@when("I try to create a tool message without tool_call_id")
def session_model_try_tool_message_no_call_id(context: Context) -> None:
"""Attempt to create a tool message without tool_call_id."""
context.session_model_error = None
try:
_make_message(role=MessageRole.TOOL, content="Result", tool_call_id=None)
except ValidationError as e:
context.session_model_error = e
@when('I create a tool message with tool_call_id "{call_id}"')
def session_model_create_tool_message(context: Context, call_id: str) -> None:
"""Create a tool message with tool_call_id."""
context.session_message = _make_message(
role=MessageRole.TOOL, content="Result", tool_call_id=call_id
)
context.session_model_error = None
@when('I create a user message "{content}"')
def session_model_create_user_message(context: Context, content: str) -> None:
"""Create a user message."""
context.session_message = _make_message(role=MessageRole.USER, content=content)
context.session_model_error = None
@then("the session message should be created")
def session_model_check_message_created(context: Context) -> None:
"""Verify the message was created."""
assert context.session_message is not None, "Message should be created"
@then('the session message tool_call_id should be "{expected}"')
def session_model_check_message_tool_call_id(context: Context, expected: str) -> None:
"""Check the message tool_call_id."""
assert context.session_message.tool_call_id == expected, (
f"Expected tool_call_id '{expected}', "
f"got '{context.session_message.tool_call_id}'"
)
# ---------------------------------------------------------------------------
# Content Validation Steps
# ---------------------------------------------------------------------------
@when("I try to create a message with whitespace-only content")
def session_model_try_whitespace_content(context: Context) -> None:
"""Attempt to create a message with whitespace-only content."""
context.session_model_error = None
try:
_make_message(content=" \t\n ")
except ValidationError as e:
context.session_model_error = e
@when("I try to create a message with empty content")
def session_model_try_empty_content(context: Context) -> None:
"""Attempt to create a message with empty content."""
context.session_model_error = None
try:
_make_message(content="")
except ValidationError as e:
context.session_model_error = e
# ---------------------------------------------------------------------------
# Message Ordering Steps
# ---------------------------------------------------------------------------
@when("I try to create a session with out-of-order messages")
def session_model_try_out_of_order_messages(context: Context) -> None:
"""Attempt to create a session with out-of-order messages."""
context.session_model_error = None
context.session_model = None
msg1 = _make_message(sequence=1, content="Second")
msg2 = _make_message(sequence=0, content="First")
try:
context.session_model = _make_session(messages=[msg1, msg2])
except ValidationError as e:
context.session_model_error = e
# ---------------------------------------------------------------------------
# Plan Linking Steps
# ---------------------------------------------------------------------------
@when('I link plan "{plan_id}" to the session')
def session_model_link_plan(context: Context, plan_id: str) -> None:
"""Link a plan to the session."""
context.session_model.link_plan(plan_id)
@then("the session should have {count:d} linked plan")
def session_model_check_linked_plan_count_singular(
context: Context, count: int
) -> None:
"""Check linked plan count (singular)."""
actual = len(context.session_model.linked_plan_ids)
assert actual == count, f"Expected {count} linked plan(s), got {actual}"
@then("the session should have {count:d} linked plans")
def session_model_check_linked_plan_count_plural(context: Context, count: int) -> None:
"""Check linked plan count (plural)."""
actual = len(context.session_model.linked_plan_ids)
assert actual == count, f"Expected {count} linked plan(s), got {actual}"
# ---------------------------------------------------------------------------
# Token Usage Steps
# ---------------------------------------------------------------------------
@then("the session token usage input_tokens should be {expected:d}")
def session_model_check_token_input(context: Context, expected: int) -> None:
"""Check token usage input_tokens."""
actual = context.session_model.token_usage.input_tokens
assert actual == expected, f"Expected input_tokens {expected}, got {actual}"
@then("the session token usage output_tokens should be {expected:d}")
def session_model_check_token_output(context: Context, expected: int) -> None:
"""Check token usage output_tokens."""
actual = context.session_model.token_usage.output_tokens
assert actual == expected, f"Expected output_tokens {expected}, got {actual}"
@then("the session token usage estimated_cost should be {expected:g}")
def session_model_check_token_cost(context: Context, expected: float) -> None:
"""Check token usage estimated_cost."""
actual = context.session_model.token_usage.estimated_cost
assert abs(actual - expected) < 1e-9, (
f"Expected estimated_cost {expected}, got {actual}"
)
@when(
"I create a session with token usage {input_t:d} input"
" {output_t:d} output {cost:g} cost"
)
def session_model_create_with_token_usage(
context: Context, input_t: int, output_t: int, cost: float
) -> None:
"""Create a session with specific token usage."""
context.session_model = _make_session(
token_usage=SessionTokenUsage(
input_tokens=input_t,
output_tokens=output_t,
estimated_cost=cost,
)
)
context.session_model_error = None
@when("I try to create token usage with negative input tokens")
def session_model_try_negative_tokens(context: Context) -> None:
"""Attempt to create token usage with negative input tokens."""
context.session_model_error = None
try:
SessionTokenUsage(input_tokens=-1)
except ValidationError as e:
context.session_model_error = e
# ---------------------------------------------------------------------------
# Export Dict Steps
# ---------------------------------------------------------------------------
@given("a session with some messages")
def session_model_given_session_with_messages(context: Context) -> None:
"""Create a session with some messages."""
context.session_model = _make_session()
context.session_model.append_message(MessageRole.USER, "Hello")
context.session_model.append_message(MessageRole.ASSISTANT, "Hi there")
context.session_model_error = None
@when("I export the session")
def session_model_export(context: Context) -> None:
"""Export the session to a dict."""
context.session_export_dict = context.session_model.as_export_dict()
@then('the export dict should have key "{key}"')
def session_model_check_export_key(context: Context, key: str) -> None:
"""Check the export dict has a specific key."""
assert key in context.session_export_dict, (
f"Expected key '{key}' in export dict, "
f"keys: {list(context.session_export_dict)}"
)
@then("the export dict messages count should match session message count")
def session_model_check_export_messages_count(context: Context) -> None:
"""Check export dict messages count matches session."""
export_count = len(context.session_export_dict["messages"])
session_count = context.session_model.message_count
assert export_count == session_count, (
f"Export has {export_count} messages, session has {session_count}"
)
# ---------------------------------------------------------------------------
# CLI Dict Steps
# ---------------------------------------------------------------------------
@when("I get the session CLI dict")
def session_model_get_cli_dict(context: Context) -> None:
"""Get the CLI dict for the session."""
context.session_cli_dict = context.session_model.as_cli_dict()
@then('the session cli dict should have key "{key}"')
def session_model_check_cli_dict_key(context: Context, key: str) -> None:
"""Check the CLI dict has a specific key."""
assert key in context.session_cli_dict, (
f"Expected key '{key}' in cli dict, keys: {list(context.session_cli_dict)}"
)
# ---------------------------------------------------------------------------
# Automation Level Steps
# ---------------------------------------------------------------------------
@given('a session with automation level "{level}"')
def session_model_given_with_automation(context: Context, level: str) -> None:
"""Create a session with a specific automation level."""
context.session_model = _make_session(automation_level=level)
context.session_model.append_message(MessageRole.USER, "Test")
context.session_model_error = None
# ---------------------------------------------------------------------------
# Empty Session Property Steps
# ---------------------------------------------------------------------------
@then("the session message count should be {expected:d}")
def session_model_check_msg_count_property(context: Context, expected: int) -> None:
"""Check message_count property."""
actual = context.session_model.message_count
assert actual == expected, f"Expected {expected}, got {actual}"
@then("the session is_empty should be true")
def session_model_check_is_empty_true(context: Context) -> None:
"""Check is_empty is True."""
assert context.session_model.is_empty is True, "Expected session to be empty"
@then("the session last_message should be none")
def session_model_check_last_message_none(context: Context) -> None:
"""Check last_message is None."""
assert context.session_model.last_message is None, (
"Expected last_message to be None"
)
# ---------------------------------------------------------------------------
# get_messages Steps
# ---------------------------------------------------------------------------
@given("a session with {count:d} messages")
def session_model_given_n_messages(context: Context, count: int) -> None:
"""Create a session with N messages."""
context.session_model = _make_session()
for i in range(count):
context.session_model.append_message(MessageRole.USER, f"Message {i}")
context.session_model_error = None
@when("I get messages with no limit")
def session_model_get_messages_no_limit(context: Context) -> None:
"""Get all messages."""
context.session_messages_result = context.session_model.get_messages()
@when("I get messages with limit {limit:d}")
def session_model_get_messages_with_limit(context: Context, limit: int) -> None:
"""Get messages with a limit."""
context.session_messages_result = context.session_model.get_messages(limit=limit)
@when("I get messages with offset {offset:d}")
def session_model_get_messages_with_offset(context: Context, offset: int) -> None:
"""Get messages with an offset."""
context.session_messages_result = context.session_model.get_messages(offset=offset)
@when("I get messages with limit {limit:d} and offset {offset:d}")
def session_model_get_messages_with_limit_offset(
context: Context, limit: int, offset: int
) -> None:
"""Get messages with limit and offset."""
context.session_messages_result = context.session_model.get_messages(
limit=limit, offset=offset
)
@then("I should get {count:d} messages")
def session_model_check_messages_result_count(context: Context, count: int) -> None:
"""Check the number of messages returned."""
actual = len(context.session_messages_result)
assert actual == count, f"Expected {count} messages, got {actual}"
# ---------------------------------------------------------------------------
# Error Type Steps
# ---------------------------------------------------------------------------
@then("SessionServiceError should be an Exception subclass")
def session_model_check_service_error_base(context: Context) -> None:
"""Verify SessionServiceError inherits from Exception."""
assert issubclass(SessionServiceError, Exception)
@then("SessionNotFoundError should be a SessionServiceError subclass")
def session_model_check_not_found_error(context: Context) -> None:
"""Verify SessionNotFoundError inherits from SessionServiceError."""
assert issubclass(SessionNotFoundError, SessionServiceError)
@then("SessionExportError should be a SessionServiceError subclass")
def session_model_check_export_error(context: Context) -> None:
"""Verify SessionExportError inherits from SessionServiceError."""
assert issubclass(SessionExportError, SessionServiceError)
@then("SessionImportError should be a SessionServiceError subclass")
def session_model_check_import_error(context: Context) -> None:
"""Verify SessionImportError inherits from SessionServiceError."""
assert issubclass(SessionImportError, SessionServiceError)
# ---------------------------------------------------------------------------
# MessageRole Enum Steps
# ---------------------------------------------------------------------------
@then('the MessageRole enum should have values "{values}"')
def session_model_check_message_role_enum(context: Context, values: str) -> None:
"""Verify MessageRole enum values."""
expected = set(values.split(","))
actual = {e.value for e in MessageRole}
assert actual == expected, f"Expected {expected}, got {actual}"
# ---------------------------------------------------------------------------
# Metadata Steps
# ---------------------------------------------------------------------------
@when("I create a session with metadata")
def session_model_create_with_metadata(context: Context) -> None:
"""Create a session with metadata."""
context.session_model = _make_session(metadata={"env": "test", "version": "1.0"})
context.session_model_error = None
@then('the session metadata should contain key "{key}"')
def session_model_check_metadata_key(context: Context, key: str) -> None:
"""Check metadata contains a key."""
assert key in context.session_model.metadata, (
f"Expected key '{key}' in metadata, "
f"keys: {list(context.session_model.metadata)}"
)
@@ -69,6 +69,19 @@ from cleveragents.domain.models.core.resource import (
SandboxStrategy,
)
# Session domain model
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionExportError,
SessionImportError,
SessionMessage,
SessionNotFoundError,
SessionService,
SessionServiceError,
SessionTokenUsage,
)
# Tool and Validation domain models
from cleveragents.domain.models.core.tool import (
BindingMode,
@@ -106,6 +119,7 @@ __all__ = [
"LifecyclePlan",
"LinkedResource",
"MaxContextCount",
"MessageRole",
"NamespacedName",
"NamespacedProject",
"Operation",
@@ -133,6 +147,14 @@ __all__ = [
"ResourceCapabilities",
"ResourceSlot",
"SandboxStrategy",
"Session",
"SessionExportError",
"SessionImportError",
"SessionMessage",
"SessionNotFoundError",
"SessionService",
"SessionServiceError",
"SessionTokenUsage",
"SummaryForUpdateContextParams",
"TemporalScope",
"Tool",
@@ -0,0 +1,536 @@
"""Session domain model for CleverAgents.
A Session is a persistent conversation thread tied to an orchestrator actor.
It maintains message history across plans and serves as the user's
natural-language interface.
Sessions are managed via CLI:
agents session create [--actor <ACTOR>]
agents session list
agents session show <SESSION_ID>
agents session delete <SESSION_ID>
agents session export <SESSION_ID>
agents session import --input <FILE>
agents session tell --session <SESSION_ID> <PROMPT>
Based on docs/specification.md and ADR-004 (Pydantic Validation).
"""
from __future__ import annotations
import hashlib
import json
import re
from abc import ABC, abstractmethod
from collections import OrderedDict
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ulid import ULID
# ULID is 26 characters, Crockford's base32
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
_ULID_RE = re.compile(ULID_PATTERN)
# Actor name pattern: namespace/name (both lowercase alphanumeric with hyphens)
_ACTOR_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*/[a-z0-9][a-z0-9_-]*$")
# Export schema version
EXPORT_SCHEMA_VERSION = "1.0"
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class MessageRole(StrEnum):
"""Role of a message in a session conversation.
Follows standard chat-model conventions:
- ``user``: Human input
- ``assistant``: AI/agent response
- ``system``: System-level instructions
- ``tool``: Tool invocation result
"""
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
TOOL = "tool"
# ---------------------------------------------------------------------------
# SessionMessage
# ---------------------------------------------------------------------------
class SessionMessage(BaseModel):
"""A single message within a session conversation.
Messages are ordered by ``sequence`` within their session. Each message
has a unique ULID identifier, a role, and content.
"""
message_id: str = Field(
...,
description="Unique ULID identifier for this message",
pattern=ULID_PATTERN,
)
role: MessageRole = Field(..., description="Role of the message sender")
content: str = Field(
..., min_length=1, description="Message content (must not be whitespace-only)"
)
sequence: int = Field(..., ge=0, description="Ordering index within the session")
timestamp: datetime = Field(
default_factory=datetime.now, description="When the message was created"
)
metadata: dict[str, Any] = Field(
default_factory=dict, description="Optional metadata for the message"
)
tool_call_id: str | None = Field(
default=None,
description="Tool call identifier (required when role is 'tool')",
)
@field_validator("content", mode="before")
@classmethod
def validate_content_not_whitespace(cls: type[SessionMessage], v: str) -> str:
"""Reject whitespace-only content before strip_whitespace runs."""
if isinstance(v, str) and not v.strip():
raise ValueError("Message content must not be whitespace-only")
return v
@model_validator(mode="after")
def _validate_tool_call_id(self) -> SessionMessage:
"""Ensure tool_call_id is present when role is tool."""
if self.role == MessageRole.TOOL and not self.tool_call_id:
raise ValueError("tool_call_id is required when message role is 'tool'")
return self
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# SessionTokenUsage
# ---------------------------------------------------------------------------
class SessionTokenUsage(BaseModel):
"""Token usage tracking for a session.
Accumulates input/output token counts and estimated cost across all
messages and plan executions within the session.
"""
input_tokens: int = Field(
default=0, ge=0, description="Total input tokens consumed"
)
output_tokens: int = Field(
default=0, ge=0, description="Total output tokens generated"
)
estimated_cost: float = Field(
default=0.0, ge=0.0, description="Estimated total cost (USD)"
)
model_config = ConfigDict(
validate_assignment=True,
)
# ---------------------------------------------------------------------------
# Session
# ---------------------------------------------------------------------------
class Session(BaseModel):
"""Domain model for a Session.
A Session is a persistent conversation thread tied to an orchestrator
actor. It maintains message history across plans and serves as the
user's natural-language interface.
Sessions are identified by a ULID and may optionally be bound to a
specific actor via ``actor_name``.
"""
session_id: str = Field(
...,
description="Unique ULID identifier for this session",
pattern=ULID_PATTERN,
)
actor_name: str | None = Field(
default=None,
description="Namespaced actor reference (namespace/name format)",
)
namespace: str = Field(
default="local",
description="Namespace for session ownership",
)
messages: list[SessionMessage] = Field(
default_factory=list,
description="Ordered list of messages in this session",
)
linked_plan_ids: list[str] = Field(
default_factory=list,
description="ULID references to linked plans",
)
automation_level: str | None = Field(
default=None,
description="Session-level automation override",
)
token_usage: SessionTokenUsage = Field(
default_factory=SessionTokenUsage,
description="Accumulated token usage for this session",
)
created_at: datetime = Field(
default_factory=datetime.now,
description="When the session was created",
)
updated_at: datetime = Field(
default_factory=datetime.now,
description="When the session was last modified",
)
metadata: dict[str, Any] = Field(
default_factory=dict,
description="Arbitrary session metadata",
)
# -- Validators ---------------------------------------------------------
@field_validator("actor_name")
@classmethod
def validate_actor_name(cls: type[Session], v: str | None) -> str | None:
"""Validate actor name matches namespace/name pattern."""
if v is None:
return None
if not _ACTOR_NAME_PATTERN.match(v):
raise ValueError(
"actor_name must match 'namespace/name' pattern "
"(lowercase alphanumeric with hyphens/underscores)"
)
return v
@model_validator(mode="after")
def _validate_message_ordering(self) -> Session:
"""Ensure messages are ordered by sequence."""
if len(self.messages) > 1:
sequences = [m.sequence for m in self.messages]
if sequences != sorted(sequences):
raise ValueError("Messages must be ordered by sequence")
return self
# -- Properties ---------------------------------------------------------
@property
def message_count(self) -> int:
"""Return the number of messages in this session."""
return len(self.messages)
@property
def last_message(self) -> SessionMessage | None:
"""Return the most recent message, or None if empty."""
if not self.messages:
return None
return self.messages[-1]
@property
def is_empty(self) -> bool:
"""Return True if the session has no messages."""
return len(self.messages) == 0
# -- Methods ------------------------------------------------------------
def append_message(
self,
role: MessageRole,
content: str,
metadata: dict[str, Any] | None = None,
tool_call_id: str | None = None,
) -> SessionMessage:
"""Append a new message to the session.
Auto-generates a ULID for the message, sets the sequence number
based on existing messages, and updates the session timestamp.
Args:
role: The message role.
content: The message content.
metadata: Optional metadata dict.
tool_call_id: Required when role is 'tool'.
Returns:
The newly created SessionMessage.
"""
next_sequence = self.messages[-1].sequence + 1 if self.messages else 0
message = SessionMessage(
message_id=str(ULID()),
role=role,
content=content,
sequence=next_sequence,
timestamp=datetime.now(),
metadata=metadata or {},
tool_call_id=tool_call_id,
)
self.messages.append(message)
self.updated_at = datetime.now()
return message
def get_messages(
self, limit: int | None = None, offset: int = 0
) -> list[SessionMessage]:
"""Return messages with optional pagination.
Args:
limit: Maximum number of messages to return (None = all).
offset: Number of messages to skip from the beginning.
Returns:
A slice of the messages list.
"""
if limit is None:
return list(self.messages[offset:])
return list(self.messages[offset : offset + limit])
def link_plan(self, plan_id: str) -> None:
"""Link a plan to this session (deduplicated).
Args:
plan_id: The ULID of the plan to link.
"""
if plan_id not in self.linked_plan_ids:
self.linked_plan_ids.append(plan_id)
def as_cli_dict(self) -> OrderedDict[str, Any]:
"""Return a stable-ordered dictionary for CLI rendering.
Matches the output fields from ``agents session show``:
ID, Actor, Messages count, Created/Updated, Automation level,
Recent Messages, Linked Plans, Token Usage.
"""
result: OrderedDict[str, Any] = OrderedDict()
result["session_id"] = self.session_id
if self.actor_name:
result["actor_name"] = self.actor_name
result["namespace"] = self.namespace
result["message_count"] = self.message_count
result["created_at"] = self.created_at.isoformat()
result["updated_at"] = self.updated_at.isoformat()
if self.automation_level:
result["automation_level"] = self.automation_level
if self.messages:
recent = self.messages[-5:]
result["recent_messages"] = [
{"role": m.role.value, "content": m.content} for m in recent
]
if self.linked_plan_ids:
result["linked_plan_ids"] = list(self.linked_plan_ids)
result["token_usage"] = {
"input_tokens": self.token_usage.input_tokens,
"output_tokens": self.token_usage.output_tokens,
"estimated_cost": self.token_usage.estimated_cost,
}
if self.metadata:
result["metadata"] = dict(self.metadata)
return result
def as_export_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable dict for session export.
Includes messages, plan references, metadata, actor config,
schema version, and a checksum for integrity verification.
"""
messages_data = [
{
"message_id": m.message_id,
"role": m.role.value,
"content": m.content,
"sequence": m.sequence,
"timestamp": m.timestamp.isoformat(),
"metadata": m.metadata,
"tool_call_id": m.tool_call_id,
}
for m in self.messages
]
export: dict[str, Any] = {
"schema_version": EXPORT_SCHEMA_VERSION,
"session_id": self.session_id,
"actor_name": self.actor_name,
"namespace": self.namespace,
"messages": messages_data,
"linked_plan_ids": list(self.linked_plan_ids),
"automation_level": self.automation_level,
"token_usage": {
"input_tokens": self.token_usage.input_tokens,
"output_tokens": self.token_usage.output_tokens,
"estimated_cost": self.token_usage.estimated_cost,
},
"metadata": dict(self.metadata),
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
# Compute checksum over the canonical JSON (excluding checksum itself)
canonical = json.dumps(export, sort_keys=True, default=str)
export["checksum"] = hashlib.sha256(canonical.encode()).hexdigest()
return export
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
use_enum_values=False,
)
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class SessionServiceError(Exception):
"""Base error for session service operations."""
class SessionNotFoundError(SessionServiceError):
"""Raised when a session ID is not found."""
class SessionExportError(SessionServiceError):
"""Raised when session export fails."""
class SessionImportError(SessionServiceError):
"""Raised when session import fails (schema mismatch, corrupt data)."""
# ---------------------------------------------------------------------------
# Service Contract (Abstract)
# ---------------------------------------------------------------------------
class SessionService(ABC):
"""Abstract interface for session management.
Implementations handle persistence, ULID generation, and lifecycle
management for Session objects.
"""
@abstractmethod
def create(self, actor_name: str | None = None) -> Session:
"""Create a new session.
Args:
actor_name: Optional namespaced actor to bind to the session.
Returns:
A newly created Session with a fresh ULID.
"""
@abstractmethod
def get(self, session_id: str) -> Session:
"""Retrieve a session by ID.
Args:
session_id: The ULID of the session to retrieve.
Returns:
The Session with the given ID.
Raises:
SessionNotFoundError: If no session with the given ID exists.
"""
@abstractmethod
def list(self) -> list[Session]:
"""List all sessions.
Returns:
A list of all sessions, ordered by creation time.
"""
@abstractmethod
def delete(self, session_id: str) -> None:
"""Delete a session by ID.
Args:
session_id: The ULID of the session to delete.
Raises:
SessionNotFoundError: If no session with the given ID exists.
"""
@abstractmethod
def append_message(
self,
session_id: str,
role: MessageRole,
content: str,
metadata: dict[str, Any] | None = None,
) -> SessionMessage:
"""Append a message to a session.
Args:
session_id: The ULID of the target session.
role: The message role.
content: The message content.
metadata: Optional metadata dict.
Returns:
The newly created SessionMessage.
Raises:
SessionNotFoundError: If no session with the given ID exists.
"""
@abstractmethod
def export_session(self, session_id: str) -> dict[str, Any]:
"""Export a session as a JSON-serializable dict.
Args:
session_id: The ULID of the session to export.
Returns:
A dict containing the full session data with checksum.
Raises:
SessionNotFoundError: If no session with the given ID exists.
SessionExportError: If export fails.
"""
@abstractmethod
def import_session(self, data: dict[str, Any]) -> Session:
"""Import a session from an export dict.
Args:
data: A dict previously produced by export_session.
Returns:
The imported Session object.
Raises:
SessionImportError: If import fails (bad schema, corrupt data).
"""
@abstractmethod
def update_token_usage(
self,
session_id: str,
input_tokens: int,
output_tokens: int,
cost: float,
) -> None:
"""Update token usage counters for a session.
Args:
session_id: The ULID of the session to update.
input_tokens: Number of input tokens to add.
output_tokens: Number of output tokens to add.
cost: Estimated cost to add (USD).
Raises:
SessionNotFoundError: If no session with the given ID exists.
"""