use aion_core::{
AssistantCommandInvocation, AssistantSessionFrame, AssistantSessionId, AssistantSessionSummary,
AssistantTurnContext,
};
use aion_proto::WireError;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use super::auth::HttpCaller;
use super::error::HttpWireError;
use crate::ServerState;
use crate::assistant::sessions::{AssistantSessionError, AssistantSessions};
use crate::error::ServerError;
use crate::namespace::CallerIdentity;
use crate::namespace::grants::{ASSISTANT_SESSIONS, require_grant};
pub(crate) async fn list_assistant_sessions(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
) -> Result<Json<AssistantSessionListBody>, AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
let listed = sessions.list(caller.subject()).await?;
Ok(Json(AssistantSessionListBody { sessions: listed }))
}
pub(crate) async fn create_assistant_session(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Json(request): Json<CreateAssistantSessionRequest>,
) -> Result<(StatusCode, Json<AssistantSessionSummary>), AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
if let Some(reason) = sessions.availability().reason() {
return Err(AssistantHttpError::Session(
StatusCode::SERVICE_UNAVAILABLE,
WireError::backend(reason.to_owned()).with_error_type(NOT_COMMISSIONED_TYPE),
));
}
let summary = sessions
.create(
caller.subject(),
request.harness.as_deref(),
request.account.as_deref(),
request.title,
)
.await?;
Ok((StatusCode::CREATED, Json(summary)))
}
pub(crate) async fn current_assistant_session(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
) -> Result<Json<AssistantSessionSummary>, AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
match sessions.current(caller.subject()).await? {
Some(summary) => Ok(Json(summary)),
None => Err(AssistantHttpError::Session(
StatusCode::NOT_FOUND,
WireError::not_found_with_type(
NOT_FOUND_TYPE,
"this caller holds no assistant session that can take another turn; open one with \
POST /assistant/sessions",
),
)),
}
}
pub(crate) async fn read_assistant_session(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(id): Path<String>,
) -> Result<Json<AssistantSessionDetailBody>, AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
let (summary, transcript) = sessions.read(caller.subject(), session_id(&id)?).await?;
Ok(Json(AssistantSessionDetailBody {
summary,
transcript,
}))
}
pub(crate) async fn submit_assistant_turn(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(id): Path<String>,
Json(request): Json<AssistantTurnRequest>,
) -> Result<(StatusCode, Json<AssistantTurnAcceptedBody>), AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
let session = session_id(&id)?;
let command: Option<AssistantCommandInvocation> = request.command.map(Into::into);
if command.is_none() && request.text.trim().is_empty() {
return Err(bad_request(
"a turn carries the operator's prompt, and `text` is empty with no `command` to run; \
send the text to ask, or name a command the harness advertised",
));
}
if let Some(command) = command.as_ref()
&& command.name.trim().is_empty()
{
return Err(bad_request(
"a turn's `command.name` is empty; name the command the harness advertised, or omit \
`command` and send `text`",
));
}
let turn_id = sessions
.turn(
caller.subject(),
session,
request.text,
request.context,
command,
)
.await?;
Ok((
StatusCode::ACCEPTED,
Json(AssistantTurnAcceptedBody { turn_id }),
))
}
pub(crate) async fn push_assistant_context(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(id): Path<String>,
Json(context): Json<AssistantTurnContext>,
) -> Result<StatusCode, AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
sessions
.push_context(caller.subject(), session_id(&id)?, context)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub(crate) async fn cancel_assistant_turn(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(id): Path<String>,
) -> Result<StatusCode, AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
sessions.cancel(caller.subject(), session_id(&id)?).await?;
Ok(StatusCode::ACCEPTED)
}
pub(crate) async fn resume_assistant_session(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(id): Path<String>,
) -> Result<(StatusCode, Json<AssistantSessionSummary>), AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
let summary = sessions.resume(caller.subject(), session_id(&id)?).await?;
Ok((StatusCode::ACCEPTED, Json(summary)))
}
pub(crate) async fn delete_assistant_session(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path(id): Path<String>,
) -> Result<StatusCode, AssistantHttpError> {
let sessions = authorized(&state, &caller)?;
sessions.delete(caller.subject(), session_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Serialize)]
pub(crate) struct AssistantSessionListBody {
sessions: Vec<AssistantSessionSummary>,
}
#[derive(Debug, Serialize)]
pub(crate) struct AssistantSessionDetailBody {
summary: AssistantSessionSummary,
transcript: Vec<AssistantSessionFrame>,
}
#[derive(Debug, Serialize)]
pub(crate) struct AssistantTurnAcceptedBody {
turn_id: String,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub(crate) struct CreateAssistantSessionRequest {
harness: Option<String>,
account: Option<String>,
title: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub(crate) struct AssistantTurnRequest {
text: String,
context: Option<AssistantTurnContext>,
command: Option<AssistantCommandRequest>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AssistantCommandRequest {
name: String,
#[serde(default)]
input: Option<String>,
}
impl From<AssistantCommandRequest> for AssistantCommandInvocation {
fn from(request: AssistantCommandRequest) -> Self {
Self {
name: request.name,
input: request.input,
}
}
}
pub(crate) enum AssistantHttpError {
Wire(HttpWireError),
Session(StatusCode, WireError),
}
impl IntoResponse for AssistantHttpError {
fn into_response(self) -> Response {
match self {
Self::Wire(error) => error.into_response(),
Self::Session(status, wire) => (status, Json(wire)).into_response(),
}
}
}
impl From<ServerError> for AssistantHttpError {
fn from(error: ServerError) -> Self {
Self::Wire(HttpWireError(error.to_wire_error()))
}
}
impl From<AssistantSessionError> for AssistantHttpError {
fn from(error: AssistantSessionError) -> Self {
let (status, wire) = session_refusal(&error);
Self::Session(status, wire)
}
}
pub(crate) fn session_refusal(error: &AssistantSessionError) -> (StatusCode, WireError) {
match error {
AssistantSessionError::NotFound { session_id }
| AssistantSessionError::NotYours { session_id, .. } => (
StatusCode::NOT_FOUND,
WireError::not_found_with_type(
NOT_FOUND_TYPE,
format!("assistant session {session_id} was not found"),
),
),
AssistantSessionError::Busy { .. } => (
StatusCode::CONFLICT,
WireError::invalid_state_with_type(BUSY_TYPE, error.to_string()),
),
AssistantSessionError::Ended { .. } => (
StatusCode::CONFLICT,
WireError::invalid_state_with_type(ENDED_TYPE, error.to_string()),
),
AssistantSessionError::UnknownHarness { .. } => (
StatusCode::BAD_REQUEST,
WireError::invalid_input(error.to_string()).with_error_type(UNKNOWN_HARNESS_TYPE),
),
AssistantSessionError::UnknownAccount { .. } => (
StatusCode::BAD_REQUEST,
WireError::invalid_input(error.to_string()).with_error_type(UNKNOWN_ACCOUNT_TYPE),
),
AssistantSessionError::UnknownCommand { .. } => (
StatusCode::BAD_REQUEST,
WireError::invalid_input(error.to_string()).with_error_type(UNKNOWN_COMMAND_TYPE),
),
AssistantSessionError::NotCommissioned { .. } => (
StatusCode::SERVICE_UNAVAILABLE,
WireError::backend(error.to_string()).with_error_type(NOT_COMMISSIONED_TYPE),
),
AssistantSessionError::HarnessUnavailable { .. } => (
StatusCode::SERVICE_UNAVAILABLE,
WireError::backend(error.to_string()).with_error_type(HARNESS_UNAVAILABLE_TYPE),
),
AssistantSessionError::AccountEnvironmentAbsent { .. } => (
StatusCode::SERVICE_UNAVAILABLE,
WireError::backend(error.to_string()).with_error_type(ACCOUNT_ENVIRONMENT_TYPE),
),
AssistantSessionError::AuthRequired { .. } => (
StatusCode::BAD_GATEWAY,
WireError::backend(error.to_string()).with_error_type(AUTH_REQUIRED_TYPE),
),
AssistantSessionError::HarnessFailed { .. } => (
StatusCode::BAD_GATEWAY,
WireError::backend(error.to_string()).with_error_type(HARNESS_FAILED_TYPE),
),
AssistantSessionError::Store(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
WireError::backend(error.to_string()).with_error_type(STORE_TYPE),
),
AssistantSessionError::Internal(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
WireError::backend(error.to_string()).with_error_type(INTERNAL_TYPE),
),
}
}
fn authorized<'state>(
state: &'state ServerState,
caller: &CallerIdentity,
) -> Result<&'state AssistantSessions, AssistantHttpError> {
require_grant(caller, &ASSISTANT_SESSIONS)?;
Ok(state.assistant_sessions())
}
pub(crate) fn session_id(text: &str) -> Result<AssistantSessionId, AssistantHttpError> {
AssistantSessionId::parse(text)
.map_err(|error| AssistantHttpError::Wire(HttpWireError(invalid_id(&error.to_string()))))
}
fn bad_request(message: impl Into<String>) -> AssistantHttpError {
AssistantHttpError::Wire(HttpWireError(
WireError::invalid_input(message).with_error_type(INVALID_REQUEST_TYPE),
))
}
pub(crate) fn invalid_id(message: &str) -> WireError {
WireError::invalid_input(message.to_owned()).with_error_type(INVALID_SESSION_ID_TYPE)
}
const INVALID_SESSION_ID_TYPE: &str = "AssistantSessionIdInvalid";
const INVALID_REQUEST_TYPE: &str = "AssistantRequestInvalid";
const NOT_FOUND_TYPE: &str = "AssistantSessionNotFound";
const BUSY_TYPE: &str = "AssistantSessionBusy";
const ENDED_TYPE: &str = "AssistantSessionEnded";
const UNKNOWN_HARNESS_TYPE: &str = "AssistantHarnessUnknown";
const UNKNOWN_ACCOUNT_TYPE: &str = "AssistantAccountUnknown";
pub(crate) const UNKNOWN_COMMAND_TYPE: &str = "AssistantCommandUnknown";
pub(crate) const NOT_COMMISSIONED_TYPE: &str = "AssistantSessionsNotCommissioned";
pub(crate) const HARNESS_UNAVAILABLE_TYPE: &str = "AssistantHarnessUnavailable";
const ACCOUNT_ENVIRONMENT_TYPE: &str = "AssistantAccountEnvironmentAbsent";
const AUTH_REQUIRED_TYPE: &str = "AssistantHarnessAuthRequired";
const HARNESS_FAILED_TYPE: &str = "AssistantHarnessFailed";
const STORE_TYPE: &str = "AssistantSessionStoreFailed";
const INTERNAL_TYPE: &str = "AssistantSessionsInternal";
#[cfg(test)]
#[path = "assistant_sessions_tests.rs"]
mod assistant_sessions_tests;