use crate::agent::ReplayBound;
use crate::agent::agdx::AgdxReceipt;
use crate::agent::lease::{LeaseKey, SessionLease};
use crate::context::{Chain, Checkpoint, ContextMessage, ContextPolicy, LastN, TokenBudget};
use crate::context_scope::{ContextScope, ScopedMemory};
use crate::error::LaserError;
use crate::laser::Laser;
use crate::provenance::AgentTopic;
use crate::types::{ConversationId, MintUlid};
use futures::FutureExt;
use iggy::prelude::{Identifier, IggyExpiry, MaxTopicSize, StreamClient};
use laser_wire::agent::{
AgentEnvelope, AgentErrorBody, AgentErrorCode, AgentId, Budget, OPERATION_SESSION, RecordId,
SdkInfo, SessionEnd, SessionStart, TaskState,
};
use laser_wire::content::ContentType;
use laser_wire::control::{ControlCommand, SessionTopics};
use laser_wire::dispatch::{DisplayType, SessionRecord, display_type};
use laser_wire::framing::encode_named;
use laser_wire::validate::Validate;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const DEFAULT_SESSION_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
pub const DEFAULT_SESSION_HEARTBEAT: Duration = Duration::from_secs(60);
pub const DEFAULT_SESSION_MEMORY_NAMESPACE: &str = "agent.session";
pub const DEFAULT_SESSION_CONTEXT_TURNS: usize = 50;
pub const DEFAULT_SESSION_CONTEXT_TOKENS: usize = 4000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionPolicy {
PerCall,
PerUser,
}
impl SessionPolicy {
pub fn conversation_for(&self, key: &str) -> ConversationId {
match self {
Self::PerCall => ConversationId::new(),
Self::PerUser => ConversationId::derive(key),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TopicRetention {
expiry: IggyExpiry,
max_size: MaxTopicSize,
}
impl TopicRetention {
pub fn new(expiry: IggyExpiry, max_size: MaxTopicSize) -> Result<Self, LaserError> {
let unbounded = matches!(
max_size,
MaxTopicSize::Unlimited | MaxTopicSize::ServerDefault
);
if matches!(expiry, IggyExpiry::NeverExpire | IggyExpiry::ServerDefault) && unbounded {
return Err(LaserError::Invalid(
"agent.sessions retention must set an expiry or a size bound".to_owned(),
));
}
Ok(Self { expiry, max_size })
}
pub fn expire_after(age: Duration) -> Self {
let micros = micros(age).max(1);
Self {
expiry: IggyExpiry::ExpireDuration(iggy::prelude::IggyDuration::from(micros)),
max_size: MaxTopicSize::ServerDefault,
}
}
pub fn expiry(&self) -> IggyExpiry {
self.expiry
}
pub fn max_size(&self) -> MaxTopicSize {
self.max_size
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SessionLayout {
#[default]
Shared,
PerAgentTopic(BTreeMap<AgentId, String>),
PerAgentPartition(BTreeMap<AgentId, u32>),
SinglePartition,
}
impl SessionLayout {
pub fn per_agent_topic<A, T>(topics: impl IntoIterator<Item = (A, T)>) -> Self
where
A: Into<AgentId>,
T: Into<String>,
{
Self::PerAgentTopic(
topics
.into_iter()
.map(|(agent, topic)| (agent.into(), topic.into()))
.collect(),
)
}
pub fn per_agent_partition<A>(partitions: impl IntoIterator<Item = (A, u32)>) -> Self
where
A: Into<AgentId>,
{
Self::PerAgentPartition(
partitions
.into_iter()
.map(|(agent, partition)| (agent.into(), partition))
.collect(),
)
}
}
const REGISTRATION_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionConfig {
stream: Option<String>,
layout: Option<SessionLayout>,
idle_timeout: Duration,
heartbeat: Duration,
register_source: bool,
fail_on_dead_letter: bool,
memory_namespace: String,
context_turns: usize,
context_tokens: usize,
sdk: SdkInfo,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
stream: None,
layout: None,
idle_timeout: DEFAULT_SESSION_IDLE_TIMEOUT,
heartbeat: DEFAULT_SESSION_HEARTBEAT,
register_source: true,
fail_on_dead_letter: false,
memory_namespace: DEFAULT_SESSION_MEMORY_NAMESPACE.to_owned(),
context_turns: DEFAULT_SESSION_CONTEXT_TURNS,
context_tokens: DEFAULT_SESSION_CONTEXT_TOKENS,
sdk: SdkInfo {
language: "rust".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
},
}
}
}
impl SessionConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn stream(mut self, stream: impl Into<String>) -> Self {
self.stream = Some(stream.into());
self
}
#[must_use]
pub fn layout(mut self, layout: SessionLayout) -> Self {
self.layout = Some(layout);
self
}
#[must_use]
pub fn idle_timeout(mut self, timeout: Duration) -> Self {
self.idle_timeout = timeout;
self
}
#[must_use]
pub fn heartbeat(mut self, interval: Duration) -> Self {
self.heartbeat = interval;
self
}
#[must_use]
pub fn register_source(mut self, register: bool) -> Self {
self.register_source = register;
self
}
#[must_use]
pub fn fail_on_dead_letter(mut self, fail: bool) -> Self {
self.fail_on_dead_letter = fail;
self
}
#[must_use]
pub fn memory_namespace(mut self, namespace: impl Into<String>) -> Self {
self.memory_namespace = namespace.into();
self
}
#[must_use]
pub fn context_turns(mut self, turns: usize) -> Self {
self.context_turns = turns;
self
}
#[must_use]
pub fn context_tokens(mut self, tokens: usize) -> Self {
self.context_tokens = tokens;
self
}
#[must_use]
pub fn sdk(mut self, language: impl Into<String>, version: impl Into<String>) -> Self {
self.sdk = SdkInfo {
language: language.into(),
version: version.into(),
};
self
}
#[must_use]
pub fn stream_name(&self) -> Option<&str> {
self.stream.as_deref()
}
#[must_use]
pub fn layout_kind(&self) -> &SessionLayout {
const SHARED: &SessionLayout = &SessionLayout::Shared;
self.layout.as_ref().unwrap_or(SHARED)
}
#[must_use]
pub fn idle_timeout_value(&self) -> Duration {
self.idle_timeout
}
#[must_use]
pub fn heartbeat_value(&self) -> Duration {
self.heartbeat
}
#[must_use]
pub fn registers_source(&self) -> bool {
self.register_source
}
#[must_use]
pub fn fails_on_dead_letter(&self) -> bool {
self.fail_on_dead_letter
}
#[must_use]
pub fn memory_namespace_name(&self) -> &str {
&self.memory_namespace
}
#[must_use]
pub fn context_turn_bound(&self) -> usize {
self.context_turns
}
#[must_use]
pub fn context_token_bound(&self) -> usize {
self.context_tokens
}
#[must_use]
pub fn sdk_info(&self) -> &SdkInfo {
&self.sdk
}
fn policy(&self) -> Box<dyn ContextPolicy> {
Box::new(Chain(vec![
Box::new(LastN(self.context_turns)),
Box::new(TokenBudget::new(self.context_tokens)),
]))
}
}
pub fn derive_session_id(stream: &str, namespace: &str, label: &str) -> ConversationId {
ConversationId::derive(&format!("{stream}\u{1f}{namespace}\u{1f}{label}"))
}
impl Laser {
pub fn sessions(&self) -> Sessions {
self.sessions_with(SessionConfig::default())
}
pub fn sessions_with(&self, config: SessionConfig) -> Sessions {
let laser = match &config.stream {
Some(stream) => self.with_default_stream(stream.clone()),
None => self.clone(),
};
if let (Some(layout), Some(stream)) = (&config.layout, laser.default_stream()) {
laser.declare_layout(stream, layout.clone());
}
Sessions {
laser,
config: Arc::new(config),
lane_guard: Arc::default(),
}
}
}
#[derive(Clone)]
pub struct Sessions {
laser: Laser,
config: Arc<SessionConfig>,
lane_guard: Arc<LaneGuard>,
}
impl Sessions {
pub fn create(&self, label: impl Into<String>) -> SessionBuilder {
SessionBuilder::new(self.clone(), Some(label.into()))
}
pub fn start(&self) -> SessionBuilder {
SessionBuilder::new(self.clone(), None)
}
pub fn open(&self, conversation: ConversationId) -> Session {
Session::lens(self.laser.clone(), Arc::clone(&self.config), conversation)
.with_lane_guard(Arc::clone(&self.lane_guard))
}
pub fn config(&self) -> &SessionConfig {
&self.config
}
pub async fn bootstrap(
&self,
partitions: u32,
retention: TopicRetention,
) -> Result<SessionBootstrap, LaserError> {
let partitions = match self.config.layout_kind() {
SessionLayout::SinglePartition => 1,
_ => partitions,
};
let declared = match self.config.layout_kind() {
SessionLayout::PerAgentTopic(topics) => declared_topics(topics)?,
_ => Vec::new(),
};
self.laser.bootstrap(partitions, retention).await?;
let stream = self.stream()?.to_owned();
for topic in &declared {
crate::laser::ensure_topic_retained(
&self.laser.client(),
&stream,
topic,
partitions,
retention.expiry(),
retention.max_size(),
)
.await?;
}
self.lane_guard.observe(&self.laser).await?;
if !self.config.register_source || !self.laser.capabilities().await.sessions {
return Ok(SessionBootstrap { registered: false });
}
let stream = self.stream()?.to_owned();
let registered = match self
.laser
.publish_control(ControlCommand::RegisterSessionSource {
stream: stream.clone(),
topics: SessionTopics::All,
})
.await
{
Ok(()) => self.registration_visible(&stream).await,
Err(error) => {
tracing::warn!(stream = %stream, %error, "session source registration refused");
false
}
};
Ok(SessionBootstrap { registered })
}
pub async fn unregister(&self) -> Result<(), LaserError> {
if !self.laser.capabilities().await.sessions {
return Err(LaserError::unsupported(
"sessions",
"this deployment does not index sessions",
));
}
let stream = self.stream()?.to_owned();
self.laser
.publish_control(ControlCommand::RemoveSessionSource { stream })
.await
}
pub fn stream(&self) -> Result<&str, LaserError> {
self.laser.stream_required()
}
pub(crate) fn laser(&self) -> &Laser {
&self.laser
}
async fn registration_visible(&self, stream: &str) -> bool {
let deadline = tokio::time::Instant::now() + self.laser.publish_options().timeout;
let mut last_unavailable = None;
loop {
match self.changes(0, 1).await {
Err(LaserError::Session(
laser_wire::session::SessionError::NotRegistered(_)
| laser_wire::session::SessionError::Stale(_),
)) => {}
Ok(_) => return true,
Err(error) if error.is_unavailable() => {
last_unavailable = Some(error.to_string());
}
Err(error) => {
tracing::warn!(stream = %stream, %error, "session source registration not confirmed");
return false;
}
}
if tokio::time::Instant::now() >= deadline {
tracing::warn!(stream = %stream, last_unavailable = ?last_unavailable, "session source registration not visible before the publish timeout");
return false;
}
tokio::time::sleep(REGISTRATION_POLL_INTERVAL).await;
}
}
}
fn declared_topics(topics: &BTreeMap<AgentId, String>) -> Result<Vec<String>, LaserError> {
let mut declared: Vec<String> = Vec::new();
for topic in topics.values() {
iggy::prelude::Identifier::named(topic)?;
if topic == laser_wire::topics::AGENT_SESSIONS || topic == laser_wire::topics::AGENT_CONTROL
{
return Err(LaserError::Invalid(format!(
"a per-agent topic layout cannot declare `{topic}`"
)));
}
if !declared.contains(topic) {
declared.push(topic.clone());
}
}
Ok(declared)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionBootstrap {
pub registered: bool,
}
#[must_use = "a session builder does nothing until begin().await"]
pub struct SessionBuilder {
sessions: Sessions,
label: Option<String>,
namespace: Option<String>,
agent: Option<AgentId>,
parent: Option<ConversationId>,
root: Option<ConversationId>,
idle_timeout: Option<Duration>,
budget: Option<Budget>,
tags: Vec<String>,
id: Option<ConversationId>,
}
impl SessionBuilder {
fn new(sessions: Sessions, label: Option<String>) -> Self {
Self {
sessions,
label,
namespace: None,
agent: None,
parent: None,
root: None,
idle_timeout: None,
budget: None,
tags: Vec::new(),
id: None,
}
}
pub fn agent(mut self, agent: impl Into<AgentId>) -> Self {
let agent = agent.into();
self.agent = Some(agent);
self
}
pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
}
pub fn parent(mut self, parent: ConversationId, root: ConversationId) -> Self {
self.parent = Some(parent);
self.root = Some(root);
self
}
pub fn with_id(mut self, id: ConversationId) -> Self {
self.id = Some(id);
self
}
pub fn idle_timeout(mut self, timeout: Duration) -> Self {
self.idle_timeout = Some(timeout);
self
}
pub fn budget(mut self, budget: Budget) -> Self {
self.budget = Some(budget);
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn id(&self) -> Result<ConversationId, LaserError> {
if let Some(id) = self.id {
return Ok(id);
}
Ok(match &self.label {
Some(label) => derive_session_id(
self.sessions.stream()?,
self.namespace.as_deref().unwrap_or_default(),
label,
),
None => ConversationId::new(),
})
}
pub async fn begin(self) -> Result<(Session, SessionLease), LaserError> {
let agent = self
.agent
.clone()
.ok_or_else(|| LaserError::Invalid("a session needs an agent".to_owned()))?;
let id = self.id()?;
let config = Arc::clone(&self.sessions.config);
let idle_timeout = self.idle_timeout.unwrap_or(config.idle_timeout);
let start = SessionStart {
label: self.label,
namespace: self.namespace,
agent: agent.clone(),
sdk: config.sdk.clone(),
parent: self.parent.map(Into::into),
root: self.root.map(Into::into),
idle_timeout_micros: Some(micros(idle_timeout)),
budget: self.budget,
tags: self.tags,
};
start.validate()?;
let session = Session::lens(self.sessions.laser.clone(), config, id)
.with_lane_guard(Arc::clone(&self.sessions.lane_guard))
.as_agent(agent)
.with_ancestry(self.parent, self.root);
let generation = session.stream_generation().await?;
let lane = session.lane()?;
let status = lane
.status(OPERATION_SESSION)
.with_task_state(TaskState::Working)
.body(encode_named(&start)?)
.content_type(ContentType::Cbor);
session.with_envelope_ancestry(status).send().await?;
let lease = session.lease(generation, idle_timeout);
Ok((session, lease))
}
}
fn micros(duration: Duration) -> u64 {
u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
}
#[derive(Clone)]
struct TerminalIntent {
state: TaskState,
end: SessionEnd,
record: RecordId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct LaneIdentity {
pub stream_id: u32,
pub stream_generation: u64,
pub topic_id: u32,
pub topic_generation: u64,
pub partitions: u32,
}
#[derive(Default)]
pub(crate) struct LaneGuard {
identity: Mutex<Option<LaneIdentity>>,
registered: std::sync::atomic::AtomicBool,
}
impl LaneGuard {
pub(crate) async fn observe(&self, laser: &Laser) -> Result<LaneIdentity, LaserError> {
let stream = Identifier::named(laser.stream_required()?)?;
let client = laser.client();
let details = client
.get_stream(&stream)
.await?
.ok_or_else(|| stale_lane("the session stream does not exist"))?;
let topic = details
.topics
.iter()
.find(|topic| topic.name == laser_wire::topics::AGENT_SESSIONS)
.ok_or_else(|| stale_lane("agent.sessions does not exist"))?;
let current = LaneIdentity {
stream_id: details.id,
stream_generation: details.created_at.as_micros(),
topic_id: topic.id,
topic_generation: topic.created_at.as_micros(),
partitions: topic.partitions_count,
};
if current.partitions == 0 {
return Err(stale_lane("agent.sessions has no partitions"));
}
let mut pinned = self.identity.lock().expect("session lane identity");
match *pinned {
Some(identity) if identity != current => Err(stale_lane(
"the session lane identity or partition count changed",
)),
Some(identity) => Ok(identity),
None => {
*pinned = Some(current);
Ok(current)
}
}
}
pub(crate) async fn check(
&self,
laser: &Laser,
conversation: ConversationId,
) -> Result<LaneIdentity, LaserError> {
let identity = self.observe(laser).await?;
if !self.registered.load(std::sync::atomic::Ordering::Acquire)
&& laser.capabilities().await.sessions
{
match laser.sessions().registered_lane(conversation).await {
Ok(view)
if view
== Some((
identity.topic_id,
identity.topic_generation,
identity.partitions,
)) =>
{
self.registered
.store(true, std::sync::atomic::Ordering::Release);
}
Ok(_) => {
return Err(stale_lane(
"the registered session lane does not match the current source",
));
}
Err(LaserError::Session(laser_wire::session::SessionError::NotRegistered(_))) => {}
Err(error) => return Err(error),
}
}
Ok(identity)
}
}
fn stale_lane(message: &str) -> LaserError {
LaserError::Session(laser_wire::session::SessionError::Stale(message.to_owned()))
}
#[derive(Clone)]
pub struct Session {
pub(crate) laser: Laser,
config: Arc<SessionConfig>,
scope: ContextScope,
pub(crate) agent: Option<AgentId>,
parent: Option<ConversationId>,
root: Option<ConversationId>,
terminal: Arc<Mutex<Option<TerminalIntent>>>,
lane_guard: Arc<LaneGuard>,
pub(crate) redactor: Arc<dyn Fn(&mut serde_json::Value) + Send + Sync>,
pub(crate) state: Arc<Mutex<crate::agent::session_ops::StateCursor>>,
pub(crate) current: Option<laser_wire::graph::SourceRef>,
pub(crate) control: Option<Arc<crate::agent::control::ControlBook>>,
#[cfg(feature = "sign")]
key: Option<crate::sign::SigningKey>,
}
impl Session {
fn lens(laser: Laser, config: Arc<SessionConfig>, conversation: ConversationId) -> Self {
Self {
scope: laser.context(conversation),
laser,
config,
agent: None,
parent: None,
root: None,
terminal: Arc::new(Mutex::new(None)),
lane_guard: Arc::default(),
redactor: Arc::new(crate::agent::session_ops::default_redact),
state: Arc::default(),
current: None,
control: None,
#[cfg(feature = "sign")]
key: None,
}
}
fn with_lane_guard(mut self, guard: Arc<LaneGuard>) -> Self {
self.lane_guard = guard;
self
}
#[cfg(feature = "sign")]
#[must_use]
pub fn signed_by(mut self, key: crate::sign::SigningKey) -> Self {
self.key = Some(key);
self
}
#[must_use]
pub fn as_agent(mut self, agent: impl Into<AgentId>) -> Self {
let agent = agent.into();
self.agent = Some(agent);
self
}
pub(crate) fn with_ancestry(
mut self,
parent: Option<ConversationId>,
root: Option<ConversationId>,
) -> Self {
self.parent = parent;
self.root = root;
self
}
pub fn conversation(&self) -> ConversationId {
self.scope.conversation()
}
pub fn stream(&self) -> Result<&str, LaserError> {
self.laser.stream_required()
}
pub fn agent(&self) -> Option<&AgentId> {
self.agent.as_ref()
}
pub fn parent(&self) -> Option<ConversationId> {
self.parent
}
pub fn root(&self) -> Option<ConversationId> {
self.root
}
pub fn config(&self) -> &SessionConfig {
&self.config
}
pub fn scope(&self) -> &ContextScope {
&self.scope
}
pub async fn append(&self, envelope: AgentEnvelope) -> Result<AgdxReceipt, LaserError> {
if ConversationId::from(envelope.conversation) != self.conversation() {
return Err(LaserError::Invalid(
"an appended envelope must belong to this session".to_owned(),
));
}
self.laser
.agdx(
AgentTopic::Sessions,
envelope.source.clone(),
envelope.conversation,
)
.with_lane_guard(Arc::clone(&self.lane_guard))
.publish_envelope(envelope)
.await
}
pub async fn end(&self) -> Result<(), LaserError> {
self.state().snapshot_if_changed().await?;
self.terminate(TaskState::Completed, SessionEnd::default())
.await
}
pub async fn fail(&self, error: AgentErrorBody) -> Result<(), LaserError> {
self.terminate(
TaskState::Failed,
SessionEnd {
reason: None,
error: Some(error),
},
)
.await
}
pub async fn cancel(&self) -> Result<(), LaserError> {
self.terminate(TaskState::Canceled, SessionEnd::default())
.await
}
pub async fn run<F, Fut, T>(self, lease: SessionLease, work: F) -> Result<T, LaserError>
where
F: FnOnce(Session) -> Fut,
Fut: std::future::Future<Output = Result<T, LaserError>>,
{
let outcome = std::panic::AssertUnwindSafe(work(self.clone()))
.catch_unwind()
.await;
let result = match outcome {
Ok(Ok(value)) => self.end().await.map(|()| value),
Ok(Err(error)) => {
let _ = self.fail(error_body(error.to_string())).await;
Err(error)
}
Err(panic) => {
let mut body = error_body(panic_message(panic.as_ref()));
body.detail = Some(BTreeMap::from([(
"panic".to_owned(),
laser_wire::query::Value::Bool(true),
)]));
let _ = self.fail(body).await;
drop(lease);
std::panic::resume_unwind(panic);
}
};
drop(lease);
result
}
pub(crate) async fn pick_up(&self) -> Result<SessionLease, LaserError> {
let generation = self.stream_generation().await?;
let transition = laser_wire::agent::SessionTransition {
actor: self.agent.clone(),
acknowledges: None,
};
let lane = self.lane()?;
let status = lane
.status(OPERATION_SESSION)
.with_task_state(TaskState::Working)
.body(encode_named(&transition)?)
.content_type(ContentType::Cbor);
self.with_envelope_ancestry(status).send().await?;
Ok(self.lease(generation, self.config.idle_timeout))
}
pub(crate) async fn terminate(
&self,
state: TaskState,
end: SessionEnd,
) -> Result<(), LaserError> {
let intent = {
let mut latch = self.terminal.lock().expect("session terminal latch");
match latch.as_ref() {
Some(intent) if intent.state != state => {
return Err(LaserError::Invalid(format!(
"session already ending as {}",
intent.state
)));
}
Some(intent) => intent.clone(),
None => {
let intent = TerminalIntent {
state,
end,
record: RecordId::mint(),
};
*latch = Some(intent.clone());
intent
}
}
};
let lane = self.lane()?;
let status = lane
.status(OPERATION_SESSION)
.with_task_state(intent.state)
.with_record(intent.record)
.body(encode_named(&intent.end)?)
.content_type(ContentType::Cbor)
.last();
#[cfg(feature = "sign")]
let status = match &self.key {
Some(key) => status.signed_by(key),
None => status,
};
self.with_envelope_ancestry(status).send().await.map(|_| ())
}
pub(crate) fn lane(&self) -> Result<crate::agent::Agdx, LaserError> {
let agent = self
.agent
.clone()
.ok_or_else(|| LaserError::Invalid("this session handle has no agent".to_owned()))?;
Ok(self
.laser
.agdx(AgentTopic::Sessions, agent, self.conversation().into())
.with_lane_guard(Arc::clone(&self.lane_guard)))
}
fn with_envelope_ancestry<'a>(
&self,
send: crate::agent::AgdxSend<'a>,
) -> crate::agent::AgdxSend<'a> {
send.with_ancestry(self.parent.map(Into::into), self.root.map(Into::into))
}
async fn stream_generation(&self) -> Result<u64, LaserError> {
Ok(self
.lane_guard
.check(&self.laser, self.conversation())
.await?
.stream_generation)
}
fn lease(&self, generation: u64, idle_timeout: Duration) -> SessionLease {
let interval = self
.config
.heartbeat
.min(idle_timeout / 5)
.max(Duration::from_millis(10));
self.laser.lease_registry().acquire(
&self.laser,
LeaseKey {
stream: self.stream().unwrap_or_default().to_owned(),
stream_generation: generation,
session: self.conversation(),
},
self.agent.clone().expect("a leased session has an agent"),
interval,
)
}
pub async fn context(&self) -> Result<Vec<SessionTurn>, LaserError> {
self.context_with(self.config.policy()).await
}
pub async fn context_with(
&self,
policy: Box<dyn ContextPolicy>,
) -> Result<Vec<SessionTurn>, LaserError> {
let messages = self.scope.fetch_with(lane_topics(), policy).await?;
Ok(turns_of(messages))
}
pub fn memory(&self) -> ScopedMemory {
self.memory_in(self.config.memory_namespace.clone())
}
pub fn memory_in(&self, namespace: impl Into<String>) -> ScopedMemory {
self.scope.memory(namespace)
}
#[cfg(feature = "graph")]
pub fn graph(&self, name: impl Into<String>) -> crate::graph::GraphHandle<'_> {
self.scope.graph(name)
}
pub async fn checkpoint(&self) -> Result<Checkpoint, LaserError> {
self.scope.checkpoint(&lane_topics()).await
}
pub async fn turns_at(&self, checkpoint: Checkpoint) -> Result<Vec<SessionTurn>, LaserError> {
self.turns(ReplayBound::At(checkpoint)).await
}
pub async fn turns_since(
&self,
checkpoint: Checkpoint,
) -> Result<Vec<SessionTurn>, LaserError> {
self.turns(ReplayBound::FromCheckpoint(checkpoint)).await
}
pub async fn state_at<S, F>(
&self,
checkpoint: Checkpoint,
init: S,
fold: F,
) -> Result<S, LaserError>
where
F: FnMut(S, &SessionTurn) -> S,
{
Ok(self.turns_at(checkpoint).await?.iter().fold(init, fold))
}
pub async fn replay<S, F>(
&self,
checkpoint: Checkpoint,
init: S,
fold: F,
) -> Result<S, LaserError>
where
F: FnMut(S, &SessionTurn) -> S,
{
Ok(self.turns_since(checkpoint).await?.iter().fold(init, fold))
}
async fn turns(&self, bound: ReplayBound) -> Result<Vec<SessionTurn>, LaserError> {
let messages = self
.scope
.state(
lane_topics(),
bound,
Vec::new(),
|mut acc: Vec<ContextMessage>, message| {
acc.push(message.clone());
acc
},
)
.await?;
Ok(turns_of(messages))
}
}
fn lane_topics() -> Vec<AgentTopic<'static>> {
vec![AgentTopic::Sessions]
}
fn turns_of(messages: Vec<ContextMessage>) -> Vec<SessionTurn> {
messages
.into_iter()
.map(|message| SessionTurn {
display: match &message.envelope {
Some(envelope) => display_type(
SessionRecord::Envelope(envelope),
laser_wire::topics::AGENT_SESSIONS,
),
None => DisplayType::AgentMessage,
},
message,
})
.collect()
}
fn error_body(message: String) -> AgentErrorBody {
AgentErrorBody {
code: AgentErrorCode::Internal,
message: Some(message),
retryable: false,
detail: None,
}
}
fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
panic
.downcast_ref::<&str>()
.map(|message| (*message).to_owned())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "the session work panicked".to_owned())
}
#[derive(Debug, Clone)]
pub struct SessionTurn {
pub display: DisplayType,
pub message: ContextMessage,
}
impl SessionTurn {
pub fn text(&self) -> String {
let body = match &self.message.envelope {
Some(envelope) => &envelope.body,
None => &self.message.payload,
};
String::from_utf8_lossy(body).into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_per_user_policy_when_deriving_for_a_key_then_should_be_stable_and_distinct() {
let policy = SessionPolicy::PerUser;
assert_eq!(
policy.conversation_for("alice"),
policy.conversation_for("alice")
);
assert_ne!(
policy.conversation_for("alice"),
policy.conversation_for("bob")
);
}
#[test]
fn given_prelude_agent_ids_when_declaring_a_layout_then_should_match_the_wire_map() {
let planner = crate::types::AgentId::new("planner").unwrap();
let worker = crate::types::AgentId::new("worker").unwrap();
let wire = |name: &str| name.parse::<AgentId>().unwrap();
assert_eq!(
SessionLayout::per_agent_topic([
(planner.clone(), "planner.inbox"),
(worker.clone(), "worker.inbox")
]),
SessionLayout::PerAgentTopic(BTreeMap::from([
(wire("planner"), "planner.inbox".to_owned()),
(wire("worker"), "worker.inbox".to_owned()),
]))
);
assert_eq!(
SessionLayout::per_agent_partition([(planner, 0), (worker, 1)]),
SessionLayout::PerAgentPartition(BTreeMap::from([
(wire("planner"), 0),
(wire("worker"), 1)
]))
);
}
#[test]
fn given_per_call_policy_when_deriving_twice_then_should_be_unique() {
let policy = SessionPolicy::PerCall;
assert_ne!(policy.conversation_for("x"), policy.conversation_for("x"));
}
#[test]
fn given_labels_namespaces_and_streams_when_derived_then_should_separate_each_dimension() {
let base = derive_session_id("agents", "ops", "incident");
assert_eq!(base, derive_session_id("agents", "ops", "incident"));
assert_ne!(base, derive_session_id("other", "ops", "incident"));
assert_ne!(base, derive_session_id("agents", "desk", "incident"));
assert_ne!(base, derive_session_id("agents", "ops", "refund"));
assert_ne!(
derive_session_id("a", "bc", "d"),
derive_session_id("ab", "c", "d")
);
}
#[test]
fn given_an_unbounded_retention_when_built_then_should_be_refused() {
assert!(TopicRetention::new(IggyExpiry::NeverExpire, MaxTopicSize::Unlimited).is_err());
assert!(TopicRetention::new(IggyExpiry::NeverExpire, MaxTopicSize::ServerDefault).is_err());
assert!(
TopicRetention::new(
IggyExpiry::ExpireDuration(iggy::prelude::IggyDuration::from(86_400_000_000u64)),
MaxTopicSize::Unlimited
)
.is_ok()
);
}
#[test]
fn given_the_defaults_when_read_then_should_match_the_documented_values() {
let config = SessionConfig::default();
assert_eq!(config.idle_timeout_value(), Duration::from_secs(300));
assert_eq!(config.heartbeat_value(), Duration::from_secs(60));
assert!(config.registers_source());
assert!(!config.fails_on_dead_letter());
assert_eq!(config.sdk_info().language, "rust");
}
}