sefios 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sefios/__init__.py +23 -0
- sefios/_scope.py +123 -0
- sefios/_session_state.py +147 -0
- sefios/_state_store.py +68 -0
- sefios/cli/__init__.py +43 -0
- sefios/cli/_app.py +266 -0
- sefios/exceptions.py +23 -0
- sefios/fastapi/__init__.py +37 -0
- sefios/fastapi/_app.py +193 -0
- sefios/handlers/__init__.py +9 -0
- sefios/handlers/_cost.py +29 -0
- sefios/history_storages/__init__.py +3 -0
- sefios/history_storages/_session.py +37 -0
- sefios/middleware/__init__.py +15 -0
- sefios/middleware/_compaction.py +47 -0
- sefios/middleware/_input.py +94 -0
- sefios/middleware/_max_steps.py +36 -0
- sefios/middleware/_retry.py +61 -0
- sefios/middleware/_stagnation.py +62 -0
- sefios/policies/__init__.py +5 -0
- sefios/policies/_default.py +23 -0
- sefios/py.typed +0 -0
- sefios/sessions/__init__.py +13 -0
- sefios/sessions/_manager.py +122 -0
- sefios/state.py +125 -0
- sefios/storage/__init__.py +5 -0
- sefios/storage/_base.py +27 -0
- sefios/storage/_file.py +67 -0
- sefios/storage/_memory.py +30 -0
- sefios/tools/__init__.py +12 -0
- sefios/tools/input.py +128 -0
- sefios/tools/output.py +82 -0
- sefios/tools/web.py +38 -0
- sefios-0.1.0.dist-info/METADATA +101 -0
- sefios-0.1.0.dist-info/RECORD +37 -0
- sefios-0.1.0.dist-info/WHEEL +4 -0
- sefios-0.1.0.dist-info/licenses/LICENSE +21 -0
sefios/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Opinionated stack for building applications with the Sefia framework."""
|
|
2
|
+
|
|
3
|
+
from ._scope import SessionScope
|
|
4
|
+
from ._session_state import get_call_state_store, get_session_storage
|
|
5
|
+
from ._state_store import StateStore
|
|
6
|
+
from .exceptions import NeedsInput
|
|
7
|
+
from .state import StateContainer, StateRegistry, get_state, state
|
|
8
|
+
from .storage import FileSessionStorage, MemorySessionStorage, SessionStorage
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SessionScope",
|
|
12
|
+
"NeedsInput",
|
|
13
|
+
"SessionStorage",
|
|
14
|
+
"StateStore",
|
|
15
|
+
"MemorySessionStorage",
|
|
16
|
+
"FileSessionStorage",
|
|
17
|
+
"get_call_state_store",
|
|
18
|
+
"get_session_storage",
|
|
19
|
+
"StateContainer",
|
|
20
|
+
"StateRegistry",
|
|
21
|
+
"get_state",
|
|
22
|
+
"state",
|
|
23
|
+
]
|
sefios/_scope.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator, Callable
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import glyff
|
|
6
|
+
import glyff_file_store
|
|
7
|
+
import sefia
|
|
8
|
+
from glyff_pydantic import PydanticArgsHasher, PydanticSerializer
|
|
9
|
+
from sefia import HistoryStorage, Profile, Policy
|
|
10
|
+
from sefia.llm import LLMClient
|
|
11
|
+
|
|
12
|
+
from ._session_state import bind_session_storage
|
|
13
|
+
from .policies import DefaultPolicy
|
|
14
|
+
from .storage import FileSessionStorage, SessionStorage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionScope:
|
|
18
|
+
"""
|
|
19
|
+
Manages shared configuration for Sefia sessions and provides helpers to run
|
|
20
|
+
code within a configured session context.
|
|
21
|
+
|
|
22
|
+
``session_storage_factory`` is the seam for a custom session-state
|
|
23
|
+
persistence backend: it receives the session id and returns the
|
|
24
|
+
:class:`SessionStorage` to bind for that session. By default a
|
|
25
|
+
:class:`FileSessionStorage` under ``session_dir`` is used.
|
|
26
|
+
|
|
27
|
+
``history_storage`` selects where run history is persisted; defaults to the
|
|
28
|
+
run's glyff metadata (:class:`~sefia.history_storages.GlyffHistoryStorage`).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
session_dir: Path,
|
|
35
|
+
model: str | None = None,
|
|
36
|
+
llm_client: LLMClient | None = None,
|
|
37
|
+
policies: list[Policy] | None = None,
|
|
38
|
+
profiles: list[Profile] | None = None,
|
|
39
|
+
stream: bool = False,
|
|
40
|
+
max_steps: int | None = 25,
|
|
41
|
+
max_repair_attempts: int = 2,
|
|
42
|
+
session_storage_factory: Callable[[str], SessionStorage] | None = None,
|
|
43
|
+
history_storage: HistoryStorage | None = None,
|
|
44
|
+
):
|
|
45
|
+
self.session_dir = session_dir
|
|
46
|
+
self.model = model
|
|
47
|
+
self.llm_client = llm_client
|
|
48
|
+
self.policies = list(policies or [])
|
|
49
|
+
self.profiles = list(profiles or [])
|
|
50
|
+
self.stream = stream
|
|
51
|
+
self.max_steps = max_steps
|
|
52
|
+
self.max_repair_attempts = max_repair_attempts
|
|
53
|
+
self.session_storage_factory = session_storage_factory
|
|
54
|
+
self.history_storage = history_storage
|
|
55
|
+
|
|
56
|
+
@asynccontextmanager
|
|
57
|
+
async def session(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
session_id: str,
|
|
61
|
+
model: str | None = None,
|
|
62
|
+
stream: bool | None = None,
|
|
63
|
+
policies: list[Policy] | None = None,
|
|
64
|
+
profiles: list[Profile] | None = None,
|
|
65
|
+
) -> AsyncIterator[sefia.Session]:
|
|
66
|
+
"""Run code within a configured Sefia session context."""
|
|
67
|
+
llm_client = self.llm_client
|
|
68
|
+
resolved_model = model or self.model
|
|
69
|
+
resolved_stream = self.stream if stream is None else stream
|
|
70
|
+
|
|
71
|
+
if llm_client is None:
|
|
72
|
+
if resolved_model is None:
|
|
73
|
+
raise ValueError("Either llm_client or model must be provided.")
|
|
74
|
+
try:
|
|
75
|
+
import sefia_litellm
|
|
76
|
+
except ImportError as e:
|
|
77
|
+
raise ImportError(
|
|
78
|
+
"The 'litellm' extra is required to use the default session "
|
|
79
|
+
"setup. Please install it with: pip install 'sefios[litellm]'"
|
|
80
|
+
) from e
|
|
81
|
+
llm_client = sefia_litellm.LiteLLMClient(model=resolved_model)
|
|
82
|
+
|
|
83
|
+
serializer = PydanticSerializer()
|
|
84
|
+
|
|
85
|
+
backend = glyff_file_store.JsonFileBackend(
|
|
86
|
+
base_dir=self.session_dir / "glyff_sessions",
|
|
87
|
+
session_id=session_id,
|
|
88
|
+
)
|
|
89
|
+
gs = glyff.Session(
|
|
90
|
+
id=session_id,
|
|
91
|
+
backend=backend,
|
|
92
|
+
serializer=serializer,
|
|
93
|
+
hasher=PydanticArgsHasher(),
|
|
94
|
+
)
|
|
95
|
+
if self.session_storage_factory is not None:
|
|
96
|
+
session_storage = self.session_storage_factory(session_id)
|
|
97
|
+
else:
|
|
98
|
+
session_storage = FileSessionStorage(
|
|
99
|
+
base_dir=self.session_dir / "sefia_metadata" / session_id,
|
|
100
|
+
serializer=serializer,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
final_policies: list[Policy] = list(self.policies)
|
|
104
|
+
if policies is not None:
|
|
105
|
+
final_policies.extend(policies)
|
|
106
|
+
final_policies.append(DefaultPolicy(max_steps=self.max_steps))
|
|
107
|
+
|
|
108
|
+
final_profiles: list[Profile] = list(self.profiles)
|
|
109
|
+
if profiles is not None:
|
|
110
|
+
final_profiles.extend(profiles)
|
|
111
|
+
|
|
112
|
+
async with gs:
|
|
113
|
+
with bind_session_storage(session_storage):
|
|
114
|
+
async with sefia.Session(
|
|
115
|
+
llm_client=llm_client,
|
|
116
|
+
glyff_session=gs,
|
|
117
|
+
policies=final_policies,
|
|
118
|
+
profiles=final_profiles,
|
|
119
|
+
stream=resolved_stream,
|
|
120
|
+
history_storage=self.history_storage,
|
|
121
|
+
max_repair_attempts=self.max_repair_attempts,
|
|
122
|
+
) as session:
|
|
123
|
+
yield session
|
sefios/_session_state.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The session-scoped state binding and its accessor functions.
|
|
2
|
+
|
|
3
|
+
This is the low-level, string-keyed tier of the state API, meant for tool
|
|
4
|
+
implementations: :func:`get_call_state_store` for call-scoped resumable state,
|
|
5
|
+
:func:`get_session_storage` for raw key-value access. Application and handler
|
|
6
|
+
state should normally go through the type-keyed container returned by
|
|
7
|
+
:func:`sefios.get_state` instead.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextvars
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import Type, TypeVar
|
|
18
|
+
|
|
19
|
+
from glyff import ExecutionId
|
|
20
|
+
from glyff import get_context as get_glyff_context
|
|
21
|
+
from glyff.exceptions import ContextNotSetError
|
|
22
|
+
|
|
23
|
+
from ._state_store import StateStore
|
|
24
|
+
from .storage import SessionStorage
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _execution_id_to_data(execution_id: ExecutionId) -> dict[str, object]:
|
|
30
|
+
parent_id = execution_id.parent_id
|
|
31
|
+
return {
|
|
32
|
+
"name": execution_id.name,
|
|
33
|
+
"sequence": execution_id.sequence,
|
|
34
|
+
"args_hash": execution_id.args_hash,
|
|
35
|
+
"parent_id": _execution_id_to_data(parent_id) if parent_id else None,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _execution_id_scope_key(execution_id: ExecutionId) -> str:
|
|
40
|
+
data = _execution_id_to_data(execution_id)
|
|
41
|
+
stable_repr = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
42
|
+
return hashlib.sha256(stable_repr.encode("utf-8")).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _SessionState:
|
|
46
|
+
"""Internal per-session binding over a :class:`SessionStorage`.
|
|
47
|
+
|
|
48
|
+
Hands out typed, caching :class:`StateStore` views of the storage — either
|
|
49
|
+
keyed directly (session scope) or scoped to the current engraved call (call
|
|
50
|
+
scope). Not exported: callers use the module-level accessor functions.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, storage: SessionStorage):
|
|
54
|
+
self._storage = storage
|
|
55
|
+
self._state_stores: dict[str, StateStore] = {}
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def storage(self) -> SessionStorage:
|
|
59
|
+
"""The underlying :class:`SessionStorage`."""
|
|
60
|
+
return self._storage
|
|
61
|
+
|
|
62
|
+
def get_call_state_store(
|
|
63
|
+
self, key_suffix: str, state_type: Type[T]
|
|
64
|
+
) -> StateStore[T]:
|
|
65
|
+
try:
|
|
66
|
+
glyff_ctx = get_glyff_context()
|
|
67
|
+
except ContextNotSetError:
|
|
68
|
+
glyff_ctx = None
|
|
69
|
+
current_execution_id = (
|
|
70
|
+
glyff_ctx.current_execution_id if glyff_ctx is not None else None
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if current_execution_id is None:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"get_call_state_store can only be used inside an engraved function."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
scope_key = _execution_id_scope_key(current_execution_id)
|
|
79
|
+
scoped_key = f"call_state/{scope_key}/{key_suffix}"
|
|
80
|
+
return self.get_state_store(scoped_key, state_type)
|
|
81
|
+
|
|
82
|
+
def get_state_store(self, key: str, state_type: Type[T]) -> StateStore[T]:
|
|
83
|
+
if key not in self._state_stores:
|
|
84
|
+
self._state_stores[key] = StateStore(
|
|
85
|
+
storage=self._storage,
|
|
86
|
+
key=key,
|
|
87
|
+
state_type=state_type,
|
|
88
|
+
)
|
|
89
|
+
store = self._state_stores[key]
|
|
90
|
+
if store.state_type != state_type:
|
|
91
|
+
raise TypeError(
|
|
92
|
+
f"State store for key '{key}' was already created with a different type."
|
|
93
|
+
)
|
|
94
|
+
return store
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_session_state_var = contextvars.ContextVar[_SessionState]("sefios_session_state")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _get_session_state() -> _SessionState:
|
|
101
|
+
try:
|
|
102
|
+
return _session_state_var.get()
|
|
103
|
+
except LookupError:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"No sefios session state is bound. Are you running outside a "
|
|
106
|
+
"SessionScope.session() (or bind_session_storage) block?"
|
|
107
|
+
) from None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_session_storage() -> SessionStorage:
|
|
111
|
+
"""Returns the :class:`SessionStorage` bound to the current session.
|
|
112
|
+
|
|
113
|
+
This is the raw key-value escape hatch for application code that manages
|
|
114
|
+
its own keys. Raises ``RuntimeError`` if called outside an active session
|
|
115
|
+
(for example, outside ``SessionScope.session()``).
|
|
116
|
+
"""
|
|
117
|
+
return _get_session_state().storage
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_call_state_store(key_suffix: str, state_type: Type[T]) -> StateStore[T]:
|
|
121
|
+
"""Returns a :class:`StateStore` scoped to the current engraved call.
|
|
122
|
+
|
|
123
|
+
The store's key is derived from the call's execution id, so a resumed
|
|
124
|
+
invocation that re-enters the same engraved call reads back the same state
|
|
125
|
+
it stored before pausing. Raises ``RuntimeError`` outside an engraved
|
|
126
|
+
function or an active session.
|
|
127
|
+
"""
|
|
128
|
+
return _get_session_state().get_call_state_store(key_suffix, state_type)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_state_store(key: str, state_type: Type[T]) -> StateStore[T]:
|
|
132
|
+
"""Returns the session-scoped :class:`StateStore` for ``key``.
|
|
133
|
+
|
|
134
|
+
Prefer the type-keyed :func:`sefios.get_state` container in application
|
|
135
|
+
code; this exists for callers that must manage string keys themselves.
|
|
136
|
+
"""
|
|
137
|
+
return _get_session_state().get_state_store(key, state_type)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@contextmanager
|
|
141
|
+
def bind_session_storage(storage: SessionStorage) -> Iterator[None]:
|
|
142
|
+
"""Binds ``storage`` as the current session's state storage."""
|
|
143
|
+
token = _session_state_var.set(_SessionState(storage))
|
|
144
|
+
try:
|
|
145
|
+
yield
|
|
146
|
+
finally:
|
|
147
|
+
_session_state_var.reset(token)
|
sefios/_state_store.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Generic, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
from .storage import SessionStorage
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StateStore(Generic[T]):
|
|
11
|
+
"""
|
|
12
|
+
Provides a type-safe, caching wrapper around a SessionStorage for a specific key and state type.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, storage: SessionStorage, key: str, state_type: Type[T]):
|
|
16
|
+
self._storage = storage
|
|
17
|
+
self._key = key
|
|
18
|
+
self._state_type = state_type
|
|
19
|
+
self._cache: T | None = None
|
|
20
|
+
self._is_loaded = False
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def state_type(self) -> Type[T]:
|
|
24
|
+
"""The state type this store was created for."""
|
|
25
|
+
return self._state_type
|
|
26
|
+
|
|
27
|
+
async def ensure(self) -> T:
|
|
28
|
+
"""
|
|
29
|
+
Ensures the state is loaded. If it doesn't exist, returns a
|
|
30
|
+
default-initialized instance. Result is cached for subsequent calls.
|
|
31
|
+
"""
|
|
32
|
+
if self._is_loaded:
|
|
33
|
+
if self._cache is None:
|
|
34
|
+
self._cache = self._state_type()
|
|
35
|
+
return self._cache
|
|
36
|
+
|
|
37
|
+
state = await self._storage.get(self._key, self._state_type)
|
|
38
|
+
if state is None:
|
|
39
|
+
state = self._state_type()
|
|
40
|
+
self._cache = state
|
|
41
|
+
self._is_loaded = True
|
|
42
|
+
return state
|
|
43
|
+
|
|
44
|
+
async def get(self, default: T | None = None) -> T | None:
|
|
45
|
+
"""
|
|
46
|
+
Returns the state if it exists, otherwise default (None if not specified).
|
|
47
|
+
Result is cached for subsequent calls.
|
|
48
|
+
"""
|
|
49
|
+
if self._is_loaded:
|
|
50
|
+
return self._cache if self._cache is not None else default
|
|
51
|
+
|
|
52
|
+
self._cache = await self._storage.get(self._key, self._state_type)
|
|
53
|
+
self._is_loaded = True
|
|
54
|
+
return self._cache if self._cache is not None else default
|
|
55
|
+
|
|
56
|
+
async def save(self, state: T) -> None:
|
|
57
|
+
"""Saves the new state and updates the cache."""
|
|
58
|
+
if not isinstance(state, self._state_type):
|
|
59
|
+
raise TypeError(f"State must be an instance of {self._state_type.__name__}")
|
|
60
|
+
await self._storage.set(self._key, state, self._state_type)
|
|
61
|
+
self._cache = state
|
|
62
|
+
self._is_loaded = True
|
|
63
|
+
|
|
64
|
+
async def delete(self) -> None:
|
|
65
|
+
"""Deletes the state and clears the cache."""
|
|
66
|
+
await self._storage.delete(self._key)
|
|
67
|
+
self._cache = None
|
|
68
|
+
self._is_loaded = True
|
sefios/cli/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Typer (CLI) integration for the sefios stack.
|
|
2
|
+
|
|
3
|
+
The facade over the ``sefia_typer`` building blocks: :class:`SefiaCLI` wires
|
|
4
|
+
the CLI input core to sefios' :class:`Input`, session storage,
|
|
5
|
+
and cost accounting. The ``sefia_typer`` surface that applications need
|
|
6
|
+
(reporters and exceptions) is re-exported here, so a single
|
|
7
|
+
``from sefios.cli import ...`` suffices.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from importlib.util import find_spec
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from sefia_typer import (
|
|
14
|
+
AmbiguousInputError,
|
|
15
|
+
CLIReporter,
|
|
16
|
+
DefaultCLIReporter,
|
|
17
|
+
InputRequest,
|
|
18
|
+
ResolvedSession,
|
|
19
|
+
UnknownInputError,
|
|
20
|
+
UnknownSessionError,
|
|
21
|
+
)
|
|
22
|
+
except ImportError as e:
|
|
23
|
+
if find_spec("sefia_typer") is None:
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"The 'cli' extra is required to use sefios.cli. "
|
|
26
|
+
"Please install it with: pip install 'sefios[cli]'"
|
|
27
|
+
) from e
|
|
28
|
+
raise
|
|
29
|
+
|
|
30
|
+
from ._app import CostReportingCLIReporter, SefiaCLI, SefiaCLISession
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"SefiaCLI",
|
|
34
|
+
"SefiaCLISession",
|
|
35
|
+
"CLIReporter",
|
|
36
|
+
"DefaultCLIReporter",
|
|
37
|
+
"CostReportingCLIReporter",
|
|
38
|
+
"InputRequest",
|
|
39
|
+
"ResolvedSession",
|
|
40
|
+
"UnknownSessionError",
|
|
41
|
+
"UnknownInputError",
|
|
42
|
+
"AmbiguousInputError",
|
|
43
|
+
]
|
sefios/cli/_app.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import AsyncIterator, Awaitable
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TypeVar, cast
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from sefia import Policy
|
|
9
|
+
from sefia.exceptions import InferenceError, PauseException
|
|
10
|
+
from sefia_typer import (
|
|
11
|
+
CLIReporter,
|
|
12
|
+
DefaultCLIReporter,
|
|
13
|
+
InputChannel,
|
|
14
|
+
)
|
|
15
|
+
from sefia_typer import InputRequest as CLIInputRequest
|
|
16
|
+
from sefia_typer import OutputMessage as CLIOutputMessage
|
|
17
|
+
from sefia_typer import ResolvedSession as CLIResolvedSession
|
|
18
|
+
from sefia_typer import UnknownSessionError as CLIUnknownSessionError
|
|
19
|
+
|
|
20
|
+
from .._scope import SessionScope
|
|
21
|
+
from .._session_state import get_session_storage
|
|
22
|
+
from ..handlers import CostCalculator, CostState
|
|
23
|
+
from ..sessions import ResolvedSession, SessionManager, UnknownSessionError
|
|
24
|
+
from ..state import get_state
|
|
25
|
+
from ..tools import Input, InputRequest, InputResult, Output, OutputMessage
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
MaybeAwaitable = T | Awaitable[T]
|
|
29
|
+
_USE_DEFAULT_REPORTER = object()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CostReportingCLIReporter(CLIReporter):
|
|
33
|
+
"""Wraps a reporter to also echo the session's total cost.
|
|
34
|
+
|
|
35
|
+
Reads the :class:`CostState` accumulated by the scope's
|
|
36
|
+
:class:`CostCalculator`, so it only works inside a session run by
|
|
37
|
+
:class:`SefiaCLI`. This is the default reporter of :class:`SefiaCLI`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, inner: CLIReporter | None = None):
|
|
41
|
+
self._inner = inner or DefaultCLIReporter()
|
|
42
|
+
|
|
43
|
+
def on_session_resolved(self, session: CLIResolvedSession) -> MaybeAwaitable[None]:
|
|
44
|
+
return self._inner.on_session_resolved(session)
|
|
45
|
+
|
|
46
|
+
def on_input_request(self, request: CLIInputRequest) -> MaybeAwaitable[None]:
|
|
47
|
+
return self._inner.on_input_request(request)
|
|
48
|
+
|
|
49
|
+
def on_input_prompt_delta(self, text: str) -> MaybeAwaitable[None]:
|
|
50
|
+
return self._inner.on_input_prompt_delta(text)
|
|
51
|
+
|
|
52
|
+
def on_output(self, message: CLIOutputMessage) -> MaybeAwaitable[None]:
|
|
53
|
+
return self._inner.on_output(message)
|
|
54
|
+
|
|
55
|
+
def on_output_message_delta(self, text: str) -> MaybeAwaitable[None]:
|
|
56
|
+
return self._inner.on_output_message_delta(text)
|
|
57
|
+
|
|
58
|
+
async def on_interrupted(self, session: CLIResolvedSession) -> None:
|
|
59
|
+
await _maybe_await(self._inner.on_interrupted(session))
|
|
60
|
+
await self._echo_total_cost()
|
|
61
|
+
|
|
62
|
+
async def on_inference_error(self, error: InferenceError) -> None:
|
|
63
|
+
await _maybe_await(self._inner.on_inference_error(error))
|
|
64
|
+
await self._echo_total_cost()
|
|
65
|
+
|
|
66
|
+
async def on_session_finished(self) -> None:
|
|
67
|
+
await _maybe_await(self._inner.on_session_finished())
|
|
68
|
+
await self._echo_total_cost()
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
async def _echo_total_cost() -> None:
|
|
72
|
+
cost_state = await get_state().get(CostState).ensure()
|
|
73
|
+
typer.echo()
|
|
74
|
+
typer.secho(f"> Total cost: ${cost_state.cost:.4f}", bold=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SefiaCLISession:
|
|
78
|
+
"""Operations available inside a Sefia CLI session context."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, *, channel: InputChannel):
|
|
81
|
+
self._input = channel
|
|
82
|
+
|
|
83
|
+
async def accept_input(
|
|
84
|
+
self,
|
|
85
|
+
input_value: str | list[str] | None,
|
|
86
|
+
*,
|
|
87
|
+
reply_to: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Store CLI input for a pending or upcoming interaction."""
|
|
90
|
+
await self._input.receive_input(input_value, reply_to=reply_to)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SefiaCLI:
|
|
94
|
+
"""Creates Sefia session contexts for Typer commands.
|
|
95
|
+
|
|
96
|
+
The integration facade over the ``sefia_typer`` building blocks: it wires
|
|
97
|
+
the CLI input core to :class:`Input` and the bound session
|
|
98
|
+
storage, runs sessions through a :class:`SessionScope` (with cost
|
|
99
|
+
accounting installed), and maps pauses and inference errors to CLI exit
|
|
100
|
+
codes.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
session_dir: Path,
|
|
107
|
+
reporter: CLIReporter | None | object = _USE_DEFAULT_REPORTER,
|
|
108
|
+
model: str | None = None,
|
|
109
|
+
stream: bool = True,
|
|
110
|
+
max_steps: int | None = 25,
|
|
111
|
+
policies: list[Policy] | None = None,
|
|
112
|
+
):
|
|
113
|
+
self._reporter = self._resolve_reporter(reporter)
|
|
114
|
+
self._session_manager = SessionManager(session_dir)
|
|
115
|
+
self._input = InputChannel(
|
|
116
|
+
on_request=self._report_input_request,
|
|
117
|
+
on_prompt_delta=self._report_input_prompt_delta,
|
|
118
|
+
namespace="cli/input_channel",
|
|
119
|
+
)
|
|
120
|
+
self._input_tool = Input(
|
|
121
|
+
get_input=self._provide_input,
|
|
122
|
+
on_request=self._record_request,
|
|
123
|
+
on_complete=self._complete_request,
|
|
124
|
+
on_prompt_delta=self._input.notify_prompt_delta,
|
|
125
|
+
)
|
|
126
|
+
self._output_tool = Output(
|
|
127
|
+
on_output=self._report_output,
|
|
128
|
+
on_message_delta=self._report_output_message_delta,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
scope_policies: list[Policy] = [Policy(handlers=lambda: [CostCalculator()])]
|
|
132
|
+
if policies is not None:
|
|
133
|
+
scope_policies.extend(policies)
|
|
134
|
+
self._session_scope = SessionScope(
|
|
135
|
+
session_dir=session_dir,
|
|
136
|
+
model=model,
|
|
137
|
+
stream=stream,
|
|
138
|
+
max_steps=max_steps,
|
|
139
|
+
policies=scope_policies,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def input_tool(self) -> Input:
|
|
144
|
+
return self._input_tool
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def output_tool(self) -> Output:
|
|
148
|
+
return self._output_tool
|
|
149
|
+
|
|
150
|
+
def create_session(self) -> str:
|
|
151
|
+
"""Create a new active CLI session and return its ID."""
|
|
152
|
+
return self._session_manager.create_new_active_session()
|
|
153
|
+
|
|
154
|
+
def switch_session(self, session_id: str) -> str:
|
|
155
|
+
"""Switch the active CLI session and return its ID."""
|
|
156
|
+
try:
|
|
157
|
+
return self._session_manager.switch_active_session(session_id)
|
|
158
|
+
except UnknownSessionError as e:
|
|
159
|
+
raise CLIUnknownSessionError(e.session_id) from None
|
|
160
|
+
|
|
161
|
+
def get_active_session(self) -> str | None:
|
|
162
|
+
"""Return the active CLI session ID, if any."""
|
|
163
|
+
return self._session_manager.get_active_session_id()
|
|
164
|
+
|
|
165
|
+
@asynccontextmanager
|
|
166
|
+
async def session(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
session_id: str | None = None,
|
|
170
|
+
model: str | None = None,
|
|
171
|
+
stream: bool | None = None,
|
|
172
|
+
policies: list[Policy] | None = None,
|
|
173
|
+
) -> AsyncIterator[SefiaCLISession]:
|
|
174
|
+
"""Run code within a resolved Sefia CLI session context."""
|
|
175
|
+
try:
|
|
176
|
+
resolved_session = self._session_manager.resolve_session(session_id)
|
|
177
|
+
except UnknownSessionError as e:
|
|
178
|
+
raise CLIUnknownSessionError(e.session_id) from None
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
await self._report_session_resolved(resolved_session)
|
|
182
|
+
async with self._session_scope.session(
|
|
183
|
+
session_id=resolved_session.session_id,
|
|
184
|
+
model=model,
|
|
185
|
+
stream=stream,
|
|
186
|
+
policies=policies,
|
|
187
|
+
):
|
|
188
|
+
with self._input.use_store(get_session_storage()):
|
|
189
|
+
try:
|
|
190
|
+
yield SefiaCLISession(channel=self._input)
|
|
191
|
+
except InferenceError as e:
|
|
192
|
+
await self._report_inference_error(e)
|
|
193
|
+
raise
|
|
194
|
+
except PauseException:
|
|
195
|
+
# Any pause (NeedsInput, or a future pause type) is a
|
|
196
|
+
# graceful interrupt, not a failure. The session context
|
|
197
|
+
# is still alive here, so reporters may read running
|
|
198
|
+
# state (e.g. cost) via get_state().
|
|
199
|
+
await self._report_interrupted(resolved_session)
|
|
200
|
+
raise
|
|
201
|
+
else:
|
|
202
|
+
await self._report_session_finished()
|
|
203
|
+
except InferenceError:
|
|
204
|
+
raise typer.Exit(code=1) from None
|
|
205
|
+
except PauseException:
|
|
206
|
+
raise typer.Exit(code=0)
|
|
207
|
+
|
|
208
|
+
async def _provide_input(self, request: InputRequest) -> str | None:
|
|
209
|
+
return await self._input.provide_input(request.interaction_id)
|
|
210
|
+
|
|
211
|
+
async def _record_request(self, request: InputRequest) -> None:
|
|
212
|
+
await self._input.record_request(request.interaction_id, request.prompt)
|
|
213
|
+
|
|
214
|
+
async def _complete_request(self, result: InputResult) -> None:
|
|
215
|
+
await self._input.complete_request(result.interaction_id)
|
|
216
|
+
|
|
217
|
+
async def _report_session_resolved(self, session: ResolvedSession) -> None:
|
|
218
|
+
if self._reporter is not None:
|
|
219
|
+
await _maybe_await(self._reporter.on_session_resolved(session))
|
|
220
|
+
|
|
221
|
+
async def _report_input_request(self, request: CLIInputRequest) -> None:
|
|
222
|
+
if self._reporter is not None:
|
|
223
|
+
await _maybe_await(self._reporter.on_input_request(request))
|
|
224
|
+
|
|
225
|
+
async def _report_input_prompt_delta(self, text: str) -> None:
|
|
226
|
+
if self._reporter is not None:
|
|
227
|
+
await _maybe_await(self._reporter.on_input_prompt_delta(text))
|
|
228
|
+
|
|
229
|
+
async def _report_output(self, message: OutputMessage) -> None:
|
|
230
|
+
if self._reporter is not None:
|
|
231
|
+
await _maybe_await(
|
|
232
|
+
self._reporter.on_output(
|
|
233
|
+
CLIOutputMessage(
|
|
234
|
+
interaction_id=message.interaction_id,
|
|
235
|
+
message=message.message,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def _report_output_message_delta(self, text: str) -> None:
|
|
241
|
+
if self._reporter is not None:
|
|
242
|
+
await _maybe_await(self._reporter.on_output_message_delta(text))
|
|
243
|
+
|
|
244
|
+
async def _report_interrupted(self, session: ResolvedSession) -> None:
|
|
245
|
+
if self._reporter is not None:
|
|
246
|
+
await _maybe_await(self._reporter.on_interrupted(session))
|
|
247
|
+
|
|
248
|
+
async def _report_inference_error(self, error: InferenceError) -> None:
|
|
249
|
+
if self._reporter is not None:
|
|
250
|
+
await _maybe_await(self._reporter.on_inference_error(error))
|
|
251
|
+
|
|
252
|
+
async def _report_session_finished(self) -> None:
|
|
253
|
+
if self._reporter is not None:
|
|
254
|
+
await _maybe_await(self._reporter.on_session_finished())
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _resolve_reporter(reporter: CLIReporter | None | object) -> CLIReporter | None:
|
|
258
|
+
if reporter is _USE_DEFAULT_REPORTER:
|
|
259
|
+
return CostReportingCLIReporter()
|
|
260
|
+
return cast(CLIReporter | None, reporter)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def _maybe_await(value: MaybeAwaitable[T]) -> T:
|
|
264
|
+
if inspect.isawaitable(value):
|
|
265
|
+
return await value
|
|
266
|
+
return value
|
sefios/exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from sefia.exceptions import PauseException
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NeedsInput(PauseException):
|
|
5
|
+
"""
|
|
6
|
+
Raised by an input-awaiting tool to pause the run until input is available.
|
|
7
|
+
|
|
8
|
+
Carries the ``prompt`` shown to whoever provides the input, and the
|
|
9
|
+
``interaction_id`` identifying the paused request so integration layers
|
|
10
|
+
can report exactly which request is waiting without re-reading state.
|
|
11
|
+
Catch it to surface the pause to your caller; once the input is recorded,
|
|
12
|
+
re-invoking the same session replays the completed steps and re-runs the
|
|
13
|
+
tool, which now returns the input.
|
|
14
|
+
|
|
15
|
+
It subclasses :class:`sefia.exceptions.PauseException`, so the sefia executor
|
|
16
|
+
propagates it as a pause (never reporting it as a failure) without the core
|
|
17
|
+
needing to know about external input specifically.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, prompt: str, *, interaction_id: str | None = None) -> None:
|
|
21
|
+
super().__init__(prompt)
|
|
22
|
+
self.prompt = prompt
|
|
23
|
+
self.interaction_id = interaction_id
|