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 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
@@ -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