Problem
Sessions are per-agent global server state. The agent registry stores a single entry.session_id (canonical active session), updated by switch_agent_session at librefang-kernel/src/kernel/mod.rs:6779-6781:
self.registry.update_session_id(agent_id, session_id)?;
All send paths (HTTP POST /api/agents/{id}/message, WebSocket /api/agents/{id}/ws, and internal send_message* variants in kernel/mod.rs) read this single field. There is no per-request session_id parameter — the body/frame cannot override it.
This breaks naturally-concurrent usage:
- Multiple dashboard tabs on the same agent with different session pickers: last "switch" wins globally. Tab A sends → message lands in whichever session Tab B last switched to. Tab A's UI continues showing its old session's history while new messages from the server belong to the other session. Confusing and hard to reason about.
- Programmatic clients (scripts hitting the REST API) that want to keep multiple sessions warm for the same agent must serialize everything through
switch → send → switch back. That's racy against the UI and against other clients.
- Channel-backed sessions already sidestep this with deterministic
SessionId::for_channel(agent, \"<channel>:<chat>\"), proving the kernel can thread a session identity through the send path. The UI/REST path just doesn't expose the hook.
Proposed shape
Add an optional session_id to the public send surface. Resolution order inside send_message_full:
- Explicit
session_id parameter (new)
- Channel-derived session (existing:
SessionId::for_channel(...) when sender_context has a non-empty channel and use_canonical_session is false)
- Registry's canonical
entry.session_id (existing fallback — unchanged behavior when the new param is omitted)
HTTP
POST /api/agents/{agent_id}/message
{
\"message\": \"...\",
\"session_id\": \"<optional-sid>\" // ← new
}
WebSocket
First frame (or per-frame) can carry session_id:
{ \"type\": \"message\", \"text\": \"...\", \"session_id\": \"<sid>\" }
Kernel surface
Either a new variant (send_message_in_session(agent_id, session_id, message, ...)) or add an Option<SessionId> parameter to send_message_full and have existing helpers pass None. The latter is smaller and keeps the helper fan-out manageable.
Safety checks
- If the caller supplies a
session_id that doesn't belong to agent_id, reject with the same error shape switch_agent_session already uses (Session belongs to a different agent).
- The per-agent
agent_msg_locks mutex (kernel/mod.rs:3847) should be scoped to (agent_id, session_id) when the override is set, not just agent_id. Otherwise concurrent sends from two tabs into different sessions of the same agent block each other for no good reason. A single-session caller (no override) keeps the old agent-scoped lock.
What this unblocks
- Dashboard: tabs can carry
?agentId=...&sessionId=... and pin themselves. No global state mutation on switch — switch_agent_session becomes purely a "set the default for callers who don't specify" operation.
- Scripting: run parallel conversations against one agent across multiple sessions without
switch_agent_session races.
- Future: lays the foundation for explicit session fan-out (one agent serving N concurrent users through distinct sessions).
Non-goals
- Not removing
switch_agent_session or the canonical session concept — triggers, cron, channel-less callers still need a sensible default. This is additive.
- Not changing channel routing. Channel-derived session identity stays the way it is (deterministic per channel + chat).
- Not touching session_mode semantics —
persistent vs new still applies to whatever session is eventually selected.
Tests
- Unit: resolution order (explicit > channel > registry).
- Unit: wrong-agent
session_id rejected.
- Integration: two concurrent POSTs to the same agent with different
session_ids complete without cross-contamination; each session's history contains exactly the messages sent to it.
- Integration: concurrent sends are serialized within a session but parallelized across sessions of the same agent.
Context
Filed alongside #2941 (per-tool timeout override). These are both "the global-per-agent knob is too coarse" problems — this one on session identity, that one on tool timeouts.
Happy to PR. Flagging for direction first because the kernel surface change touches several send-path helpers.
Problem
Sessions are per-agent global server state. The agent registry stores a single
entry.session_id(canonical active session), updated byswitch_agent_sessionatlibrefang-kernel/src/kernel/mod.rs:6779-6781:All send paths (HTTP
POST /api/agents/{id}/message, WebSocket/api/agents/{id}/ws, and internalsend_message*variants inkernel/mod.rs) read this single field. There is no per-requestsession_idparameter — the body/frame cannot override it.This breaks naturally-concurrent usage:
switch → send → switch back. That's racy against the UI and against other clients.SessionId::for_channel(agent, \"<channel>:<chat>\"), proving the kernel can thread a session identity through the send path. The UI/REST path just doesn't expose the hook.Proposed shape
Add an optional
session_idto the public send surface. Resolution order insidesend_message_full:session_idparameter (new)SessionId::for_channel(...)whensender_contexthas a non-empty channel anduse_canonical_sessionis false)entry.session_id(existing fallback — unchanged behavior when the new param is omitted)HTTP
WebSocket
First frame (or per-frame) can carry
session_id:{ \"type\": \"message\", \"text\": \"...\", \"session_id\": \"<sid>\" }Kernel surface
Either a new variant (
send_message_in_session(agent_id, session_id, message, ...)) or add anOption<SessionId>parameter tosend_message_fulland have existing helpers passNone. The latter is smaller and keeps the helper fan-out manageable.Safety checks
session_idthat doesn't belong toagent_id, reject with the same error shapeswitch_agent_sessionalready uses (Session belongs to a different agent).agent_msg_locksmutex (kernel/mod.rs:3847) should be scoped to(agent_id, session_id)when the override is set, not justagent_id. Otherwise concurrent sends from two tabs into different sessions of the same agent block each other for no good reason. A single-session caller (no override) keeps the old agent-scoped lock.What this unblocks
?agentId=...&sessionId=...and pin themselves. No global state mutation on switch —switch_agent_sessionbecomes purely a "set the default for callers who don't specify" operation.switch_agent_sessionraces.Non-goals
switch_agent_sessionor the canonical session concept — triggers, cron, channel-less callers still need a sensible default. This is additive.persistentvsnewstill applies to whatever session is eventually selected.Tests
session_idrejected.session_ids complete without cross-contamination; each session's history contains exactly the messages sent to it.Context
Filed alongside #2941 (per-tool timeout override). These are both "the global-per-agent knob is too coarse" problems — this one on session identity, that one on tool timeouts.
Happy to PR. Flagging for direction first because the kernel surface change touches several send-path helpers.