Skip to content
Merged
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
46 changes: 46 additions & 0 deletions .changeset/memory-provider-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@smooai/smooth-operator": patch
---

feat(dotnet,python,ts,go): let a host supply the turn's memory — durable auto-recall off Rust (th-ebe27d)

Rust #330 put `memory_for_access` on `StorageAdapter` and had the server runner thread the
result into the engine's agent options, which is what lights up Big Smooth's durable
auto-recall. The four sibling servers never did — and the gap was invisible, because **all
five engine cores already implement `Memory` and already recall relevant entries into
context**. The capability was fully built on both ends with nothing connecting them: no
matter what store a deployment had, every turn on these servers ran without auto-recall.

Each server now takes a `MemoryProvider` (`IMemoryProvider` in C#) with one method —
`memory_for_access(access)` — resolved per turn and passed to the engine as
`AgentOptions.memory`:

| | seam | install |
|---|---|---|
| C# | `IMemoryProvider` | DI (`services.GetService<IMemoryProvider>()`) |
| Python | `MemoryProvider` | `ServerState.memory_provider` |
| TypeScript | `MemoryProvider` | `serve({ memoryProvider })` |
| Go | `MemoryProvider` | `WithMemoryProvider(...)` |

`access` is threaded exactly as it is for knowledge, so a multi-tenant host can bind memory
to the requester's org/user; single-tenant hosts — Big Smooth's daemon, the reason the seam
exists — ignore it, so each language also ships a `StaticMemoryProvider` over one store.

**Nothing changes for anyone who does not opt in.** No provider, or a provider that returns
nothing for this caller, leaves the turn byte-for-byte what it was — and that is a test, not
a claim: each language asserts the no-provider and the declining-provider paths inject
nothing, alongside the positive case and a relevance case (an unrelated message recalls
nothing, so this is not a blanket dump of every stored memory into every turn).

Five tests per language, named after their Rust counterparts in
`rust/smooth-operator-server/tests/injection_seams.rs`, all four mutation-checked — dropping
the one line that hands memory to the engine fails them.

Two notes for whoever picks this up next. The recall block's **header text is deliberately
not asserted**: the five cores currently inject three different strings for it (th-ffaeae),
so the tests assert the recalled *content* reaches the model, which is the behavior the seam
exists for. And the bundled lexical scorer counts raw token overlap with **no stopword
filter**, so in practice a single shared "the" scores a hit — worth knowing before trusting
recall precision in production.

No wire-protocol change.
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ private static FrameDispatcher BuildDispatcher(HttpContext context)
// fall back to the env-configured directory resolver (SMOOTH_SKILLS_DIR). Unset ⇒ null ⇒ any
// `skill` field is a clean SKILL_NOT_FOUND, so a multi-tenant deploy never serves host skills
// by accident. Mirrors Rust's install_skill_resolver_from_env.
skillResolver: services.GetService<ISkillResolver>() ?? DirSkillResolver.FromEnv());
skillResolver: services.GetService<ISkillResolver>() ?? DirSkillResolver.FromEnv(),
// Durable auto-recall (th-ebe27d / Rust #330). A host registers an IMemoryProvider — or a
// StaticMemoryProvider over one store — to light it up; unregistered ⇒ no auto-recall.
memoryProvider: services.GetService<IMemoryProvider>());
}

private static async Task PumpAsync(
Expand Down
14 changes: 12 additions & 2 deletions dotnet/server/src/FrameDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public sealed class FrameDispatcher
private readonly TurnLimits _limits;
private readonly ILogger? _logger;
private readonly ISkillResolver? _skillResolver;
/// <summary>Supplies the turn's durable-recall store, scoped to this connection's access
/// (th-ebe27d / Rust #330). Null → no auto-recall, the default.</summary>
private readonly IMemoryProvider? _memoryProvider;

// The connection's SINGLE in-flight send_message turn, if one is running. A turn that calls a
// confirmation-gated tool parks awaiting a later confirm_tool_action frame, so the turn runs as a
Expand Down Expand Up @@ -102,14 +105,16 @@ public FrameDispatcher(
TurnLimits? limits = null,
ILogger? logger = null,
IReadOnlyList<IToolHook>? toolHooks = null,
ISkillResolver? skillResolver = null)
ISkillResolver? skillResolver = null,
IMemoryProvider? memoryProvider = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient));
_knowledge = knowledge;
_access = access ?? AccessContext.Anonymous;
_systemPrompt = systemPrompt;
_skillResolver = skillResolver;
_memoryProvider = memoryProvider;
_reranker = reranker;
_tools = tools ?? Array.Empty<AITool>();
// Tool-call hooks (surveillance / redaction) forwarded to every turn's registry (empty → no
Expand Down Expand Up @@ -784,6 +789,11 @@ private async Task HandleSendMessageAsync(JsonObject frame, string? requestId, A
// through the same ACL-filtered store (a doc the caller's groups don't grant is never a candidate).
var scopedKnowledge = _knowledge?.ForAccess(_access);

// Durable auto-recall, scoped the same way retrieval is: the host decides which memory this
// caller recalls from. Null (no provider installed, or one that declines this access) leaves
// the turn without auto-recall — byte-for-byte unchanged.
var scopedMemory = _memoryProvider?.MemoryForAccess(_access);

// Built-in knowledge_search: a model-callable search over the connection's ACL-scoped knowledge
// (parity with the Rust server's KnowledgeSearchTool). Prepended before the enabled_tools filter
// so it flows through the SAME per-agent restriction + auth gate as every other tool — an agent
Expand Down Expand Up @@ -824,7 +834,7 @@ private async Task HandleSendMessageAsync(JsonObject frame, string? requestId, A
// have arrived on a PREVIOUS connection (a reconnect resumes the conversation on a fresh
// dispatcher), and a per-connection map would read empty there. th-13df6d.
var capabilities = await _store.GetClientSupportsAsync(session.ConversationId, cancellationToken).ConfigureAwait(false);
var runner = new TurnRunner(_chatClient, _store, scopedKnowledge, _systemPrompt, _reranker, gatedTools, confirmTools, _confirmations, agentConfig, _judge, _limits, _logger, toolHooks: _toolHooks, interactions: _interactions, interactionPark: _interactionPark, capabilities: capabilities, interactionEffects: _sessionIdentity)
var runner = new TurnRunner(_chatClient, _store, scopedKnowledge, _systemPrompt, _reranker, gatedTools, confirmTools, _confirmations, agentConfig, _judge, _limits, _logger, toolHooks: _toolHooks, interactions: _interactions, interactionPark: _interactionPark, capabilities: capabilities, interactionEffects: _sessionIdentity, memory: scopedMemory)
{
ConfirmationTimeout = ConfirmationTimeout,
};
Expand Down
42 changes: 42 additions & 0 deletions dotnet/server/src/Memory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using SmooAI.SmoothOperator.Core;

namespace SmooAI.SmoothOperator.Server;

/// <summary>
/// Supplies the durable-recall handle for a turn — the C# analog of the Rust
/// <c>StorageAdapter::memory_for_access</c> seam (PR #330).
///
/// The engine already knows how to auto-recall: give <c>AgentOptions.Memory</c> a store and it
/// pulls the entries relevant to the user's message into the turn's context. What was missing on
/// this server is the way for a HOST to say <em>which</em> store — so every turn ran without
/// auto-recall regardless of what the deployment had.
///
/// The <c>access</c> argument is threaded (mirroring <c>IKnowledgeBase.ForAccess</c>) so a
/// multi-tenant backend can bind memory to the requester's org/user; a single-tenant host — Big
/// Smooth's daemon, which is the reason this seam exists — ignores it and returns its one store.
/// </summary>
public interface IMemoryProvider
{
/// <summary>
/// The memory to auto-recall from for a caller with this access, or <c>null</c> for none.
/// <c>null</c> is the default for every deployment that has not opted in, and leaves the turn
/// byte-for-byte unchanged.
/// </summary>
IAgentMemory? MemoryForAccess(AccessContext access);
}

/// <summary>
/// An <see cref="IMemoryProvider"/> over one unscoped store — the single-tenant case (Big Smooth's
/// daemon hands its SQLite-backed store straight through). A multi-tenant host implements the
/// interface itself and keys off <c>access</c> instead.
/// </summary>
public sealed class StaticMemoryProvider : IMemoryProvider
{
private readonly IAgentMemory? _memory;

/// <param name="memory">The store every caller recalls from; <c>null</c> disables auto-recall.</param>
public StaticMemoryProvider(IAgentMemory? memory) => _memory = memory;

/// <inheritdoc />
public IAgentMemory? MemoryForAccess(AccessContext access) => _memory;
}
10 changes: 9 additions & 1 deletion dotnet/server/src/TurnRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ public sealed class TurnRunner
private readonly IChatClient _chatClient;
private readonly ISessionStore _store;
private readonly IKnowledgeBase? _knowledge;
/// <summary>Durable-recall store for this turn, already resolved for the connection's access by
/// the dispatcher (th-ebe27d / Rust #330). Null → the turn runs without auto-recall.</summary>
private readonly IAgentMemory? _memory;
private readonly IReranker? _reranker;
private readonly string _systemPrompt;
private readonly IReadOnlyList<AITool> _tools;
Expand Down Expand Up @@ -96,11 +99,12 @@ public sealed class TurnRunner
/// — or a test — narrows it).</summary>
public TimeSpan ConfirmationTimeout { get; init; } = DefaultConfirmationTimeout;

public TurnRunner(IChatClient chatClient, ISessionStore store, IKnowledgeBase? knowledge = null, string? systemPrompt = null, IReranker? reranker = null, IReadOnlyList<AITool>? tools = null, IReadOnlyList<string>? confirmTools = null, ConfirmationRegistry? confirmations = null, AgentConfig? agentConfig = null, IWorkflowJudge? judge = null, TurnLimits? limits = null, ILogger? logger = null, IChatClient? preambleChatClient = null, IReadOnlyList<IToolHook>? toolHooks = null, InteractionCatalog? interactions = null, InteractionParkRegistry? interactionPark = null, IReadOnlyCollection<string>? capabilities = null, SessionIdentityRegistry? interactionEffects = null)
public TurnRunner(IChatClient chatClient, ISessionStore store, IKnowledgeBase? knowledge = null, string? systemPrompt = null, IReranker? reranker = null, IReadOnlyList<AITool>? tools = null, IReadOnlyList<string>? confirmTools = null, ConfirmationRegistry? confirmations = null, AgentConfig? agentConfig = null, IWorkflowJudge? judge = null, TurnLimits? limits = null, ILogger? logger = null, IChatClient? preambleChatClient = null, IReadOnlyList<IToolHook>? toolHooks = null, InteractionCatalog? interactions = null, InteractionParkRegistry? interactionPark = null, IReadOnlyCollection<string>? capabilities = null, SessionIdentityRegistry? interactionEffects = null, IAgentMemory? memory = null)
{
_chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient));
_store = store ?? throw new ArgumentNullException(nameof(store));
_knowledge = knowledge;
_memory = memory;
_reranker = reranker;
_systemPrompt = systemPrompt ??
"You are a helpful customer support agent. Answer using only the knowledge provided to you; if it is not there, say you don't know.";
Expand Down Expand Up @@ -331,6 +335,10 @@ public async Task<TurnResult> RunAsync(string conversationId, string requestId,
MaxIterations = _limits.MaxIterations,
MaxOutputTokens = _limits.MaxTokens,
ModelMaxOutputTokens = _limits.ModelMaxOutputTokens,
// Durable auto-recall: with a store attached the engine pulls the entries relevant to the
// user's message into context. Null (every deployment that has not opted in) leaves the
// turn byte-for-byte unchanged.
Memory = _memory,
};
foreach (var tool in _tools)
{
Expand Down
124 changes: 124 additions & 0 deletions dotnet/server/tests/MemoryProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Text.Json.Nodes;
using SmooAI.SmoothOperator.Core;
using SmooAI.SmoothOperator.Server;

namespace SmooAI.SmoothOperator.Server.Tests;

/// <summary>
/// Durable auto-recall parity with the Rust reference (PR #330 — the
/// <c>StorageAdapter::memory_for_access</c> seam, tested in
/// <c>rust/smooth-operator-server/tests/injection_seams.rs</c>).
///
/// The engine already knew how to recall; what was missing on this server was the host's way to say
/// WHICH store, so every turn ran without auto-recall no matter what the deployment had. These tests
/// are named after their Rust counterparts so a parity gap stays visible.
///
/// The recall block's header text is deliberately NOT asserted here: the five cores currently inject
/// three different strings for it (th-ffaeae). The assertion is on the recalled CONTENT reaching the
/// model, which is the behavior the seam exists for.
/// </summary>
public class MemoryProviderTests
{
private static async Task<string> CreateSessionAsync(FrameDispatcher dispatcher, List<JsonObject> events)
{
await dispatcher.DispatchAsync("""{"action":"create_conversation_session","agentId":"11111111-1111-1111-1111-111111111111","requestId":"r1"}""", events.Add);
var sessionId = events[0]["data"]!["sessionId"]!.GetValue<string>();
events.Clear();
return sessionId;
}

/// <summary>Everything the model was sent this turn, flattened — the surface a recalled memory
/// must show up in.</summary>
private static string AllContentSeen(RecordingChatClient chat) =>
string.Join("\n", chat.LastMessages.Select(m => m.Text));

private static async Task<RecordingChatClient> RunTurnAsync(IMemoryProvider? provider, string message)
{
var chat = new RecordingChatClient("ok");
var dispatcher = new FrameDispatcher(new InMemorySessionStore(), chat, memoryProvider: provider);
var events = new List<JsonObject>();
var sessionId = await CreateSessionAsync(dispatcher, events);

await dispatcher.DispatchAsync(
$$"""{"action":"send_message","requestId":"r2","sessionId":"{{sessionId}}","message":"{{message}}"}""",
events.Add);
await dispatcher.WaitForTurnsAsync();
return chat;
}

// ── rust: no_memory_means_no_recall_injection ────────────────────────────

/// <summary>Default: no provider ⇒ no auto-recall. Guards against the seam injecting when absent —
/// an unopted deployment's turn must be byte-for-byte what it was before.</summary>
[Fact]
public async Task NoMemoryMeansNoRecallInjection()
{
var chat = await RunTurnAsync(null, "add shows to my watchlist");
Assert.DoesNotContain("smoo-hub watchlist", AllContentSeen(chat), StringComparison.Ordinal);
}

/// <summary>A provider that returns null for this caller is the same as no provider — the seam
/// must not fabricate a store just because one was installed.</summary>
[Fact]
public async Task ProviderReturningNullMeansNoRecallInjection()
{
var chat = await RunTurnAsync(new StaticMemoryProvider(null), "add shows to my watchlist");
Assert.DoesNotContain("smoo-hub watchlist", AllContentSeen(chat), StringComparison.Ordinal);
}

// ── rust: attached_memory_is_auto_recalled_into_the_turn ─────────────────

/// <summary>With a store attached the engine recalls the entries relevant to the user's message
/// and injects them into the turn — the seam that lights up Big Smooth's durable auto-recall.</summary>
[Fact]
public async Task AttachedMemoryIsAutoRecalledIntoTheTurn()
{
var memory = new InMemoryAgentMemory();
await memory.StoreAsync(new MemoryEntry("m-1", "always add shows to the smoo-hub watchlist", MemoryType.Project));

// The message shares "add", "shows", "watchlist" with the stored entry, so the engine's
// word-overlap recall surfaces it.
var chat = await RunTurnAsync(new StaticMemoryProvider(memory), "add shows to my watchlist");

Assert.Contains("smoo-hub watchlist", AllContentSeen(chat), StringComparison.Ordinal);
}

/// <summary>An unrelated message recalls nothing: the seam is relevance-gated by the engine, not a
/// blanket dump of every stored memory into every turn. The message shares NO token with the entry —
/// the bundled lexical scorer counts raw token overlap with no stopword filter, so a single shared
/// "the" is enough to score a hit.</summary>
[Fact]
public async Task IrrelevantMessageRecallsNothing()
{
var memory = new InMemoryAgentMemory();
await memory.StoreAsync(new MemoryEntry("m-1", "always add shows to the smoo-hub watchlist", MemoryType.Project));

var chat = await RunTurnAsync(new StaticMemoryProvider(memory), "explain quantum entanglement");

Assert.DoesNotContain("smoo-hub watchlist", AllContentSeen(chat), StringComparison.Ordinal);
}

/// <summary>The seam is access-scoped (mirroring <c>IKnowledgeBase.ForAccess</c>) so a multi-tenant
/// host can bind memory to the requester — the argument must actually reach the provider.</summary>
[Fact]
public async Task ProviderSeesTheCallersAccess()
{
var seen = new List<AccessContext>();
var chat = await RunTurnAsync(new RecordingMemoryProvider(seen), "hello");

Assert.Single(seen);
}

private sealed class RecordingMemoryProvider : IMemoryProvider
{
private readonly List<AccessContext> _seen;

public RecordingMemoryProvider(List<AccessContext> seen) => _seen = seen;

public IAgentMemory? MemoryForAccess(AccessContext access)
{
_seen.Add(access);
return null;
}
}
}
10 changes: 10 additions & 0 deletions go/server/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ type FrameDispatcher struct {
// is long enough. Nil → the feature is off and any skill field is a clean
// SKILL_NOT_FOUND, so a multi-tenant deploy never serves host skills by accident.
skills SkillResolver
// memoryProvider supplies the turn's durable-recall store, scoped to this connection's
// access (th-ebe27d / Rust #330). Set by the server after construction, alongside skills.
// nil → no auto-recall, the default.
memoryProvider MemoryProvider
// associate records this connection's backplane targets as they are learned (set by
// the connection loop). Nil → session/agent targets are never routable.
associate func(target Target)
Expand Down Expand Up @@ -790,6 +794,12 @@ func (d *FrameDispatcher) handleSendMessage(ctx context.Context, frame inboundFr
}()
runner := NewTurnRunner(d.client, d.store, effectiveSystemPrompt, d.knowledge, effectiveTools, d.confirmTools, d.confirmations, workflow, session.CurrentStepID, d.judgeModel, d.modelCeiling)
runner.hooks = d.hooks
// Durable auto-recall, scoped the way retrieval is: the host decides which memory this
// caller recalls from. nil (no provider, or one that declines this access) leaves the turn
// without auto-recall — byte-for-byte unchanged.
if d.memoryProvider != nil {
runner.memory = d.memoryProvider.MemoryForAccess(d.access)
}
// Rich Interactions: give the runner the hosted kinds, the park/resume registry,
// and this session's declared capabilities, so it registers one raise tool per
// kind (rich park when the capability is declared, else conversational fallback).
Expand Down
Loading
Loading