Source code for rath.session.select

"""Workflow routing decision via one-shot LLM call (:func:`select_session`)."""

from __future__ import annotations

import re
from dataclasses import replace

from rath.llm import Provider, RathLLMChatResponse, RathLLMMessage
from rath.session.chat_request_build import provider_into_chat_request
from rath.session.chunk import chunk_table_to_messages
from rath.session.loop import SessionLoopExecutor, resolve_executor
from rath.session.session import Session

_DEFAULT_SELECT_INSTRUCTION = (
    "You are a router. Given the conversation above and a numbered menu of candidate "
    "workflows, reply with ONLY the single integer index of the best-matching workflow. "
    "Reply -1 if none of them applies or the task is already complete. Output the "
    "integer and nothing else."
)

_INT_TOKEN = re.compile(r"-?\d+")


def _completion_body(resp: RathLLMChatResponse) -> str:
    choice = resp.primary_choice
    msg = choice.message
    if msg.tool_calls:
        raise RuntimeError(
            "select_session: model returned tool calls but tools are disabled"
        )
    fr = choice.finish_reason
    if fr not in ("stop", "length", "content_filter"):
        raise RuntimeError(f"select_session: unexpected finish_reason={fr!r}")
    content = msg.content
    if content is None or not str(content).strip():
        raise RuntimeError("select_session: empty model content")
    return str(content)


def _parse_index(body: str, count: int) -> int:
    """First integer token in ``body``; out-of-range / missing -> -1."""

    m = _INT_TOKEN.search(body)
    if m is None:
        return -1
    idx = int(m.group())
    return idx if 0 <= idx < count else -1


[docs] def select_session( user_session: Session, agent_session: Session, *workflow_descriptions: str, agent_provider: Provider | None = None, executor: SessionLoopExecutor | None = None, ) -> tuple[int, str]: """LLM picks the best-matching description for the current user session. Folds ``agent_session`` (the selection rubric) + ``user_session`` (current state) + a numbered menu of ``workflow_descriptions`` into a single completion (``tools=None``, ``tool_choice="none"``), parses the chosen 0-based index, and returns ``(index, workflow_descriptions[index])``. Returns ``(-1, "")`` when the model replies -1, gives no parseable index, or returns an out-of-range index — meaning no candidate fits or the session needs no further workflow. Creates no new :class:`Session` and stamps no lineage. """ if not workflow_descriptions: return (-1, "") # Explicit provider wins; otherwise fall back to the user session's bound # provider (session.to(Provider(...))). Mirrors run_session_loop (P4.4). if agent_provider is None: agent_provider = user_session.provider if agent_provider is None: raise ValueError( "no provider for select_session: pass agent_provider=Provider(...) " "or bind one on the session via session.to(Provider(...))" ) # Join lazy input sessions before reading their chunk_table. if user_session._pending is not None: user_session.synchronize() if agent_session._pending is not None: agent_session.synchronize() executor = resolve_executor( agent_provider=agent_provider, executor=executor, on_event=None ) menu = "\n".join(f"{i}: {desc}" for i, desc in enumerate(workflow_descriptions)) head = chunk_table_to_messages(agent_session.chunk_table) tail = chunk_table_to_messages(user_session.chunk_table) messages: tuple[RathLLMMessage, ...] = ( *head, *tail, RathLLMMessage( role="user", content=f"Candidate workflows:\n{menu}\n\nReply with one index, or -1.", ), ) prefs = replace(agent_provider, tool_choice=None) req = provider_into_chat_request(messages, None, prefs, default_tool_choice="none") resp = executor.complete(req) idx = _parse_index(_completion_body(resp), len(workflow_descriptions)) return (idx, workflow_descriptions[idx]) if idx >= 0 else (-1, "")
__all__ = ["select_session"]