Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions osprey_worker/src/osprey/worker/lib/ask/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Host-neutral Ask AI turn service.

This package drives a validated user turn through an LLM provider, a principal-scoped
tool registry, host persistence/locking, redaction, audit, and evidence adapters,
emitting a deterministic, bounded stream of versioned events. All product- and
vendor-specific behavior is supplied by the host through the Protocols in
:mod:`osprey.worker.lib.ask.ports`; nothing here imports Discord/Smite/SML/Druid or a
concrete vendor SDK.
"""

from osprey.worker.lib.ask.contracts import (
ASK_EVENT_VERSION,
AskEvent,
AskEventType,
AskLimits,
AskRequest,
ContextSnapshot,
Conversation,
Principal,
ResolvedModel,
)
from osprey.worker.lib.ask.errors import (
AskError,
AskErrorCode,
BudgetExceeded,
Cancelled,
Forbidden,
InternalError,
InvalidContext,
InvalidModel,
InvalidRequest,
LockUnavailable,
ProviderError,
SerializationError,
ToolExecutionError,
UnavailableProvider,
to_public_payload,
)
from osprey.worker.lib.ask.ports import (
AskConfig,
AuditSink,
ContextSnapshotProvider,
ConversationLock,
ConversationStore,
EvidenceNormalizer,
ModelPolicy,
Redactor,
ToolRegistryFactory,
)
from osprey.worker.lib.ask.service import AskService

__all__ = [
'ASK_EVENT_VERSION',
'AskEvent',
'AskEventType',
'AskLimits',
'AskRequest',
'ContextSnapshot',
'Conversation',
'Principal',
'ResolvedModel',
'AskError',
'AskErrorCode',
'BudgetExceeded',
'Cancelled',
'Forbidden',
'InternalError',
'InvalidContext',
'InvalidModel',
'InvalidRequest',
'LockUnavailable',
'ProviderError',
'SerializationError',
'ToolExecutionError',
'UnavailableProvider',
'to_public_payload',
'AskConfig',
'AuditSink',
'ContextSnapshotProvider',
'ConversationLock',
'ConversationStore',
'EvidenceNormalizer',
'ModelPolicy',
'Redactor',
'ToolRegistryFactory',
'AskService',
]
110 changes: 110 additions & 0 deletions osprey_worker/src/osprey/worker/lib/ask/contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Vendor-neutral Ask AI domain contracts.

Request, event, principal, conversation, and limit types shared across the Ask
service and its transports. No host, product, or vendor specifics live here.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, List, Literal, Mapping, Optional

from osprey.worker.lib.llm import BaseLLMProvider, LLMMessage

ASK_EVENT_VERSION = 1

AskEventType = Literal[
'conversation_started',
'tool_call',
'query_result',
'assistant_message',
'done',
'error',
]


@dataclass(frozen=True)
class Principal:
"""The server-authenticated actor a turn is scoped to.

Derived by the transport from server-side identity; it is never read from the
client request body. Ownership, permissions, and tool authorization are all
keyed off this principal.
"""

id: str
email: str
display_name: Optional[str] = None
attributes: Mapping[str, Any] = field(default_factory=dict)


@dataclass
class AskRequest:
"""A single user turn request. ``message`` is required and non-empty."""

message: str
conversation_id: Optional[str] = None
model: Optional[str] = None
context_ref: Optional[str] = None


@dataclass
class AskEvent:
"""A versioned event emitted while a turn streams.

``type`` selects the event; ``payload`` carries type-specific data. Every event
carries the schema ``version`` so clients and hosts can evolve independently.
"""

type: AskEventType
payload: dict[str, Any] = field(default_factory=dict)
conversation_id: Optional[str] = None
turn_id: Optional[str] = None
version: int = ASK_EVENT_VERSION


@dataclass(frozen=True)
class AskLimits:
"""Host-configurable bounds the service enforces on every turn."""

max_history_messages: int = 40
max_context_chars: int = 20_000
max_tool_iterations: int = 8
max_output_tokens: int = 1024
max_output_chars: int = 40_000
max_evidence_chars: int = 4_000


@dataclass
class Conversation:
"""A conversation owned by a principal. ``messages`` are oldest-first."""

id: str
principal_id: str
messages: List[LLMMessage] = field(default_factory=list)


@dataclass
class ContextSnapshot:
"""Validated grounding state referenced by a request's ``context_ref``.

Grounding input only: it never grants authorization. The host renders it to a
string via :meth:`ContextSnapshotProvider.render`.
"""

ref: str
metadata: Mapping[str, Any] = field(default_factory=dict)


@dataclass
class ResolvedModel:
"""The outcome of :meth:`ModelPolicy.resolve`.

``provider`` is a *ready* provider (SDK/credentials validated) or ``None`` when
no provider is available (mapped to ``unavailable_provider``). ``limits``
optionally overrides the service default for this turn.
"""

model: Optional[str]
provider: Optional[BaseLLMProvider]
limits: Optional[AskLimits] = None
124 changes: 124 additions & 0 deletions osprey_worker/src/osprey/worker/lib/ask/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Ask error taxonomy mapped to stable, host-safe codes.

Every error carries a stable ``code`` and a ``public_message`` that is always safe to
show a client -- it never contains a raw exception, provider payload, or credential.
``preflight`` errors are raised before any event is emitted (the transport maps them
to an HTTP status); non-preflight errors occur mid-stream and become a terminal
``error`` event.
"""

from __future__ import annotations

from typing import Dict, Literal, Optional

AskErrorCode = Literal[
'unavailable_provider',
'invalid_request',
'invalid_model',
'invalid_context',
'forbidden',
'provider_error',
'tool_error',
'budget_exceeded',
'lock_unavailable',
'serialization_error',
'cancelled',
'internal',
]


class AskError(Exception):
"""Base class for all Ask errors."""

code: AskErrorCode = 'internal'
http_status: int = 500
preflight: bool = False
public_message: str = 'An internal error occurred.'

def __init__(self, public_message: Optional[str] = None) -> None:
if public_message is not None:
self.public_message = public_message
super().__init__(self.public_message)


# --- Pre-flight errors: raised before streaming; transport returns HTTP ---


class InvalidRequest(AskError):
code: AskErrorCode = 'invalid_request'
http_status = 400
preflight = True
public_message = 'The request was invalid.'


class InvalidModel(AskError):
code: AskErrorCode = 'invalid_model'
http_status = 400
preflight = True
public_message = 'The requested model is not available.'


class InvalidContext(AskError):
code: AskErrorCode = 'invalid_context'
http_status = 400
preflight = True
public_message = 'The request context was invalid.'


class Forbidden(AskError):
code: AskErrorCode = 'forbidden'
http_status = 403
preflight = True
public_message = 'You do not have access to this conversation.'


class UnavailableProvider(AskError):
code: AskErrorCode = 'unavailable_provider'
http_status = 503
preflight = True
public_message = 'No language model provider is available.'


class LockUnavailable(AskError):
code: AskErrorCode = 'lock_unavailable'
http_status = 409
preflight = True
public_message = 'Another turn is already in progress for this conversation.'


# --- In-stream errors: surfaced as a terminal ``error`` event ---


class ProviderError(AskError):
code: AskErrorCode = 'provider_error'
public_message = 'The language model provider failed to respond.'


class ToolExecutionError(AskError):
code: AskErrorCode = 'tool_error'
public_message = 'A tool failed to execute.'


class BudgetExceeded(AskError):
code: AskErrorCode = 'budget_exceeded'
public_message = 'The response exceeded the configured limits.'


class Cancelled(AskError):
code: AskErrorCode = 'cancelled'
public_message = 'The request was cancelled.'


class SerializationError(AskError):
code: AskErrorCode = 'serialization_error'
public_message = 'The response could not be serialized.'


class InternalError(AskError):
code: AskErrorCode = 'internal'
public_message = 'An internal error occurred.'


def to_public_payload(err: AskError) -> Dict[str, str]:
"""Return the safe, client-facing ``{code, message}`` for an error."""
return {'code': err.code, 'message': err.public_message}
Loading
Loading