diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs index 579240432b6..5d4161cd544 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs @@ -30,7 +30,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class InMemoryAgentSessionStore : AgentSessionStore { - private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _sessions = new(); /// public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) @@ -51,25 +51,16 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false); } - // Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store - // partitions per agent and per user identically. Like FileSystemAgentSessionStore, the agent segment - // uses agent.Name (a stable identity) and is omitted when no name is set; agent.Id is intentionally - // NOT used because it is regenerated on every startup for in-memory-defined agents, which would break - // session continuity for a transient or recreated agent. The user segment is omitted when no user id - // is supplied. - private static string GetKey(AIAgent agent, string conversationId, string? userId) - { - string key = string.Empty; - if (!string.IsNullOrEmpty(agent.Name)) - { - key += $"a-{agent.Name}:"; - } + // Like FileSystemAgentSessionStore, the agent segment uses agent.Name (a stable identity) + // and is omitted when no name is set; agent.Id is intentionally NOT used because it is + // regenerated on every startup for in-memory-defined agents, which would break session + // continuity for a transient or recreated agent. The user segment is omitted when no user + // id is supplied. + private static SessionKey GetKey(AIAgent agent, string conversationId, string? userId) => + new( + string.IsNullOrEmpty(agent.Name) ? null : agent.Name, + string.IsNullOrWhiteSpace(userId) ? null : userId, + conversationId); - if (!string.IsNullOrWhiteSpace(userId)) - { - key += $"u-{userId}:"; - } - - return key + $"c-{conversationId}"; - } + private readonly record struct SessionKey(string? AgentName, string? UserId, string ConversationId); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs new file mode 100644 index 00000000000..2f6c41264dc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +public sealed class InMemoryAgentSessionStoreTests +{ + [Fact] + public async Task SaveSessionAsync_CompositeIdentifiersPreserveBoundariesAsync() + { + // Arrange: these identifier tuples collapse to the same colon-delimited string key. + var store = new InMemoryAgentSessionStore(); + var agent = new TestAgent("concierge"); + + // Act + await store.SaveSessionAsync(agent, "delta", new TestSession("first"), "bravo:c-charlie"); + await store.SaveSessionAsync(agent, "charlie:c-delta", new TestSession("second"), "bravo"); + var first = await store.GetSessionAsync(agent, "delta", "bravo:c-charlie"); + var second = await store.GetSessionAsync(agent, "charlie:c-delta", "bravo"); + + // Assert + Assert.Equal("first", Assert.IsType(first).Value); + Assert.Equal("second", Assert.IsType(second).Value); + } + + private sealed class TestSession(string value) : AgentSession + { + public string Value { get; } = value; + } + + private sealed class TestAgent(string name) : AIAgent + { + public override string? Name => name; + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + ValueTask.FromResult(new TestSession(string.Empty)); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + var testSession = Assert.IsType(session); + return ValueTask.FromResult(JsonSerializer.SerializeToElement(testSession.Value)); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(new TestSession(serializedState.GetString()!)); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } +} diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 2b321c67046..64a74e92c51 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -1,5 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. +import json + from agent_framework import AgentSession, SupportsAgentRun from agent_framework._telemetry import mark_feature_used from azure.ai.agentserver.core import get_request_context @@ -61,7 +63,7 @@ def _partition_key(self) -> str: "The hosted environment is missing session_id or user_id in the request context. " "Please ensure that the request is coming from a valid Foundry platform service." ) - return f"{context.session_id}:{context.user_id}" + return json.dumps([context.session_id, context.user_id], separators=(",", ":")) if not context.session_id: raise RuntimeError( diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index d5f8b180230..e842baa1be8 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -182,7 +182,7 @@ def test_hosted_returns_composite_key(self) -> None: server = InvocationsHostServer(_make_agent(response_text="hi")) server.config.is_hosted = True with _request_context(call_id="call-1", session_id="sess-1", user_id="user-1"): - assert server._partition_key() == "sess-1:user-1" # pyright: ignore[reportPrivateUsage] + assert server._partition_key() == '["sess-1","user-1"]' # pyright: ignore[reportPrivateUsage] # endregion @@ -259,5 +259,27 @@ async def test_session_is_reused_across_requests(self) -> None: assert list(server._sessions) == ["sess-1"] # pyright: ignore[reportPrivateUsage] assert agent.calls[0]["session"] is agent.calls[1]["session"] + async def test_hosted_sessions_preserve_identifier_boundaries(self) -> None: + agent = _make_agent(response_text="ok") + server = InvocationsHostServer(agent) + server.config.is_hosted = True + request = _make_request({"message": "Hi"}) + + with _request_context(session_id="sess", user_id="user:admin"): + await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + first_session = agent.calls[-1]["session"] + + with _request_context(session_id="sess:user", user_id="admin"): + await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + second_session = agent.calls[-1]["session"] + + with _request_context(session_id="sess", user_id="user:admin"): + await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + repeated_session = agent.calls[-1]["session"] + + assert first_session is not second_session + assert repeated_session is first_session + assert len(server._sessions) == 2 # pyright: ignore[reportPrivateUsage] + # endregion diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/break_glass/main.py b/python/samples/04-hosting/foundry-hosted-agents/invocations/break_glass/main.py index 102734a9e17..8e120feba25 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/break_glass/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/break_glass/main.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import json import os from collections.abc import AsyncGenerator @@ -56,7 +57,7 @@ def get_session_partition_key() -> str: "The request context is missing session_id. Please ensure that the request is a valid request." ) if context.user_id is not None: - return f"{context.session_id}:{context.user_id}" + return json.dumps([context.session_id, context.user_id], separators=(",", ":")) return context.session_id