Spaces:
Running
Running
| """Resolving the active identity into a context profile (spec Β§5, Β§6.3). | |
| This is the join between Phase 5's identity runtime and Phase 4's conversation | |
| loop. The runtime decides *who* is being talked to and hands out a | |
| :class:`~chittios_core.identity.session.Session`; this turns that session into | |
| the :class:`~chittios_core.context.profile.Profile` the conversation runs | |
| under β the persona for the system prompt, the role for the tool gate, the | |
| namespace for memory. | |
| Pure, then stateful | |
| ------------------- | |
| Two objects, because there are two jobs. :class:`ProfileResolver` is a pure | |
| function of an identity: same session in, same profile out, no state, no I/O. | |
| :class:`ContextManager` is the thin stateful shell the runtime drives β it | |
| remembers the *current* profile and swaps it when the active identity changes, | |
| so ``current_profile`` is always the one in force. Splitting them keeps the | |
| policy testable in isolation and the state trivial. | |
| Guest is the safe default | |
| ------------------------- | |
| Before anyone is recognised, and any time recognition drops, the profile is the | |
| guest one: no private memory, minimal tools, child-safe content. A | |
| :class:`ContextManager` therefore *starts* on the guest profile rather than on | |
| nothing, so there is never a window where the active persona is undefined. | |
| Why a Protocol and not ``Session`` directly | |
| -------------------------------------------- | |
| The resolver needs three facts about the active identity β is it a guest, what | |
| band is it, what namespace does it own β and nothing else. Depending on the | |
| whole :class:`~chittios_core.identity.session.Session` would couple context | |
| resolution to fields it never reads and make it awkward to test with a fake. | |
| :class:`ActiveIdentity` names exactly the three facts; ``Session`` satisfies it | |
| structurally, and a test can satisfy it with three lines. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, replace | |
| from typing import Final, Protocol, runtime_checkable | |
| from chittios_core.context.profile import ( | |
| ADULT_PROFILE, | |
| CHILD_PROFILE, | |
| GUEST_PROFILE, | |
| Profile, | |
| ) | |
| from chittios_core.identity.types import AgeBand | |
| class ActiveIdentity(Protocol): | |
| """The slice of the active identity that context resolution reads. | |
| Exactly the three facts a :class:`Profile` derives from. | |
| :class:`~chittios_core.identity.session.Session` satisfies this structurally | |
| β its ``is_guest``, ``age_band``, and ``memory_namespace`` line up β so the | |
| runtime passes its session straight in, and a test passes any object with | |
| the same three members. | |
| """ | |
| def is_guest(self) -> bool: | |
| """Whether this identity was *not* backed by a biometric match. | |
| The gate between the guest profile and a member profile. A guest is | |
| anyone the robot could not verify, however friendly or familiar. | |
| """ | |
| ... | |
| def age_band(self) -> AgeBand: | |
| """The identity's own band, selecting the child or adult persona. | |
| This is the active speaker's band, not the room's. Content-policy | |
| tightening for a child *present* while an adult speaks stays on the | |
| session (``requires_child_safe_content``) and is deliberately not folded | |
| into persona selection: the adult keeps their own voice, tools, and | |
| memory, exactly as the session boundary keeps their scopes. | |
| """ | |
| ... | |
| def memory_namespace(self) -> str: | |
| """The namespace whose private memory is in scope β the member's id.""" | |
| ... | |
| class ProfileResolver: | |
| """Turns an active identity into its context profile. Pure and injectable. | |
| The three templates are fields, not hardcoded lookups, so a household can | |
| supply its own personas without touching this logic:: | |
| resolver = ProfileResolver(adult=my_tuned_adult_profile) | |
| The defaults are the age-band profiles from :mod:`chittios_core.context.profile`. | |
| """ | |
| adult: Profile = ADULT_PROFILE | |
| child: Profile = CHILD_PROFILE | |
| guest: Profile = GUEST_PROFILE | |
| def resolve(self, active: ActiveIdentity | None) -> Profile: | |
| """Return the profile for ``active``; the guest profile for no one. | |
| A missing identity (``None``) and a guest session resolve identically β | |
| both mean "nobody is verified" β and both return the guest profile | |
| *as-is*, ignoring any namespace the caller may have attached. That is | |
| the guarantee behind "a guest never sees a member's memory": the guest | |
| profile is a constant with an empty namespace, and no verified member's | |
| id can be substituted into it here. | |
| For a verified member, the persona and role come from the age-band | |
| template and the memory namespace is re-keyed to *this* member's own | |
| namespace, so the returned profile opens their memory and no one else's. | |
| """ | |
| if active is None or active.is_guest: | |
| return self.guest | |
| template = self.child if active.age_band is AgeBand.CHILD else self.adult | |
| return replace(template, memory_namespace=active.memory_namespace) | |
| class ContextManager: | |
| """Holds the profile currently in force and swaps it when identity changes. | |
| The stateful counterpart to the pure resolver, and the object Phase 4 reads | |
| ``current_profile`` from when building a turn. It is driven by the identity | |
| runtime: on each step where the active identity changed, call | |
| :meth:`on_active_changed` with the new session. | |
| Starts on the guest profile so the active persona is defined from the first | |
| moment β before anyone is recognised, ChittiOS is already a guest-safe | |
| companion rather than a companion with no persona at all. | |
| """ | |
| def __init__( | |
| self, | |
| resolver: ProfileResolver | None = None, | |
| *, | |
| initial: Profile = GUEST_PROFILE, | |
| ) -> None: | |
| """Build a manager. | |
| Args: | |
| resolver: The resolution policy. Defaults to a standard | |
| :class:`ProfileResolver`; inject one to supply custom personas. | |
| initial: The profile in force before any identity is seen. Defaults | |
| to the guest profile, the least-privilege starting point. | |
| """ | |
| self._resolver: Final = resolver if resolver is not None else ProfileResolver() | |
| self._current: Profile = initial | |
| def current_profile(self) -> Profile: | |
| """The profile in force right now β persona, role, and memory scope.""" | |
| return self._current | |
| def on_active_changed(self, active: ActiveIdentity | None) -> Profile: | |
| """Switch the current profile to match a newly active identity. | |
| Wired to the identity runtime's ``active_changed`` signal: when the | |
| recognised person changes, the persona, tools, and memory change with | |
| them. Returns the profile now in force so a caller can react to the swap | |
| in one call. | |
| """ | |
| self._current = self._resolver.resolve(active) | |
| return self._current | |