Skip to content

Commit 46196d0

Browse files
feat: implement SessionManager and ContextBuilder (Tasks 2.12, 2.13)
- Add SqliteSessionManager with conversation history persistence - Add ContextBuilder for system prompt assembly - All tests passing - TDD approach followed
1 parent e4d53f6 commit 46196d0

9 files changed

Lines changed: 1007 additions & 0 deletions

File tree

config.example.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ default_model = "anthropic/claude-sonnet-4-20250514"
4141
base_url = "http://localhost:11434" # Ollama server URL
4242
default_model = "llama3"
4343

44+
[providers.minimax]
45+
api_key = "your-minimax-api-key" # Your MiniMax API key
46+
group_id = "your-group-id" # Your MiniMax Group ID (optional)
47+
default_model = "MiniMax-M2.5" # Default model (MiniMax-M2.1 or MiniMax-M2.5)
48+
4449
# Additional OpenAI-compatible providers
4550
# [[providers.compatible]]
4651
# api_key = "..."
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using ClawSharp.Core.Config;
2+
using ClawSharp.Core.Memory;
3+
using ClawSharp.Core.Providers;
4+
using ClawSharp.Core.Tools;
5+
6+
namespace ClawSharp.Agent;
7+
8+
/// <summary>
9+
/// Builds the full prompt context: system prompt + memory + tools + history.
10+
/// </summary>
11+
public class ContextBuilder
12+
{
13+
private readonly IMemoryStore _memoryStore;
14+
private readonly IToolRegistry _toolRegistry;
15+
private readonly ClawSharpConfig _config;
16+
17+
public ContextBuilder(
18+
IMemoryStore memoryStore,
19+
IToolRegistry toolRegistry,
20+
ClawSharpConfig config)
21+
{
22+
_memoryStore = memoryStore;
23+
_toolRegistry = toolRegistry;
24+
_config = config;
25+
}
26+
27+
/// <summary>
28+
/// Builds the full system prompt including identity, memories, and tools.
29+
/// </summary>
30+
public async Task<string> BuildSystemPromptAsync(CancellationToken ct = default)
31+
{
32+
var sections = new List<string>();
33+
34+
// Identity section
35+
sections.Add($"# Identity\nYou are ClawSharp, an AI assistant running on .NET.");
36+
sections.Add($"Current time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
37+
38+
// Memories section
39+
var memories = await _memoryStore.ListAsync(MemoryCategory.Core, limit: 20, ct: ct);
40+
if (memories.Count > 0)
41+
{
42+
sections.Add("\n# Relevant Memories");
43+
foreach (var memory in memories)
44+
{
45+
sections.Add($"- [{memory.Key}]: {memory.Content}");
46+
}
47+
}
48+
49+
// Tools section
50+
var tools = _toolRegistry.GetSpecifications();
51+
if (tools.Count > 0)
52+
{
53+
sections.Add("\n# Available Tools\nYou can use the following tools:");
54+
foreach (var tool in tools)
55+
{
56+
sections.Add($"## {tool.Name}");
57+
sections.Add(tool.Description ?? "No description");
58+
}
59+
}
60+
61+
return string.Join("\n\n", sections);
62+
}
63+
64+
/// <summary>
65+
/// Trims conversation history to fit within token budget.
66+
/// Uses a simple estimate: 4 characters ≈ 1 token.
67+
/// </summary>
68+
public List<LlmMessage> TrimHistory(List<LlmMessage> history, int maxTokens)
69+
{
70+
if (history.Count == 0)
71+
return [];
72+
73+
// Estimate tokens as characters / 4
74+
var estimatedTokens = history.Sum(m => (m.Content?.Length ?? 0) / 4);
75+
76+
if (estimatedTokens <= maxTokens)
77+
return history.ToList();
78+
79+
// Need to trim - keep the most recent messages
80+
// Aim for roughly maxTokens
81+
var trimmed = new List<LlmMessage>();
82+
var currentTokens = 0;
83+
84+
// Iterate from the most recent messages backwards
85+
for (int i = history.Count - 1; i >= 0; i--)
86+
{
87+
var msg = history[i];
88+
var msgTokens = (msg.Content?.Length ?? 0) / 4;
89+
90+
if (currentTokens + msgTokens > maxTokens)
91+
break;
92+
93+
trimmed.Insert(0, msg);
94+
currentTokens += msgTokens;
95+
}
96+
97+
return trimmed;
98+
}
99+
100+
/// <summary>
101+
/// Builds the full context for a request including system prompt, context, and messages.
102+
/// </summary>
103+
public async Task<List<LlmMessage>> BuildContextAsync(
104+
List<LlmMessage> conversationHistory,
105+
CancellationToken ct = default)
106+
{
107+
var systemPrompt = await BuildSystemPromptAsync(ct);
108+
109+
// Trim history to fit budget (estimate 8000 tokens for system prompt)
110+
var trimmedHistory = TrimHistory(conversationHistory, _config.MaxContextTokens - 8000);
111+
112+
// Add system message at the beginning
113+
var messages = new List<LlmMessage>
114+
{
115+
new("system", systemPrompt)
116+
};
117+
118+
messages.AddRange(trimmedHistory);
119+
120+
return messages;
121+
}
122+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
using ClawSharp.Core.Sessions;
2+
using Microsoft.Data.Sqlite;
3+
using System.Text.Json;
4+
5+
namespace ClawSharp.Agent;
6+
7+
/// <summary>
8+
/// SQLite-backed session manager with conversation history persistence.
9+
/// </summary>
10+
public class SqliteSessionManager : ISessionManager, IAsyncDisposable
11+
{
12+
private readonly string _connectionString;
13+
private readonly SqliteConnection _connection;
14+
private readonly JsonSerializerOptions _jsonOptions = new()
15+
{
16+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
17+
};
18+
private bool _disposed;
19+
20+
public SqliteSessionManager(string connectionString)
21+
{
22+
// Handle various connection string formats
23+
_connectionString = connectionString switch
24+
{
25+
":memory:" => "Data Source=file::memory:?cache=shared",
26+
_ when !connectionString.Contains("Data Source=", StringComparison.OrdinalIgnoreCase)
27+
=> $"Data Source={connectionString}",
28+
_ => connectionString
29+
};
30+
31+
_connection = new SqliteConnection(_connectionString);
32+
_connection.Open();
33+
InitializeSchema();
34+
}
35+
36+
private void InitializeSchema()
37+
{
38+
using var cmd = _connection.CreateCommand();
39+
cmd.CommandText = """
40+
CREATE TABLE IF NOT EXISTS sessions (
41+
session_key TEXT PRIMARY KEY,
42+
channel TEXT NOT NULL,
43+
chat_id TEXT NOT NULL,
44+
history_json TEXT NOT NULL DEFAULT '[]',
45+
summary TEXT,
46+
created TEXT NOT NULL,
47+
last_active TEXT NOT NULL
48+
);
49+
CREATE INDEX IF NOT EXISTS idx_sessions_channel_chat ON sessions(channel, chat_id);
50+
""";
51+
cmd.ExecuteNonQuery();
52+
}
53+
54+
public async Task<SessionContext> GetOrCreateAsync(string sessionKey, string channel, string chatId, CancellationToken ct = default)
55+
{
56+
await using var cmd = _connection.CreateCommand();
57+
cmd.CommandText = """
58+
SELECT session_key, channel, chat_id, history_json, summary, created, last_active
59+
FROM sessions WHERE session_key = @sessionKey
60+
""";
61+
cmd.Parameters.AddWithValue("@sessionKey", sessionKey);
62+
63+
await using var reader = await cmd.ExecuteReaderAsync(ct);
64+
if (await reader.ReadAsync(ct))
65+
{
66+
return ReadSession(reader);
67+
}
68+
69+
// Create new session
70+
var now = DateTimeOffset.UtcNow.ToString("O");
71+
await using var insertCmd = _connection.CreateCommand();
72+
insertCmd.CommandText = """
73+
INSERT INTO sessions (session_key, channel, chat_id, history_json, created, last_active)
74+
VALUES (@sessionKey, @channel, @chatId, @historyJson, @created, @lastActive)
75+
""";
76+
insertCmd.Parameters.AddWithValue("@sessionKey", sessionKey);
77+
insertCmd.Parameters.AddWithValue("@channel", channel);
78+
insertCmd.Parameters.AddWithValue("@chatId", chatId);
79+
insertCmd.Parameters.AddWithValue("@historyJson", "[]");
80+
insertCmd.Parameters.AddWithValue("@created", now);
81+
insertCmd.Parameters.AddWithValue("@lastActive", now);
82+
83+
await insertCmd.ExecuteNonQueryAsync(ct);
84+
85+
return new SessionContext
86+
{
87+
SessionKey = sessionKey,
88+
Channel = channel,
89+
ChatId = chatId,
90+
History = [],
91+
Created = DateTimeOffset.UtcNow,
92+
LastActive = DateTimeOffset.UtcNow
93+
};
94+
}
95+
96+
public async Task SaveAsync(SessionContext session, CancellationToken ct = default)
97+
{
98+
var historyJson = JsonSerializer.Serialize(session.History, _jsonOptions);
99+
var now = DateTimeOffset.UtcNow.ToString("O");
100+
101+
await using var cmd = _connection.CreateCommand();
102+
cmd.CommandText = """
103+
INSERT INTO sessions (session_key, channel, chat_id, history_json, summary, created, last_active)
104+
VALUES (@sessionKey, @channel, @chatId, @historyJson, @summary, @created, @lastActive)
105+
ON CONFLICT(session_key) DO UPDATE SET
106+
history_json = @historyJson,
107+
summary = @summary,
108+
last_active = @lastActive
109+
""";
110+
cmd.Parameters.AddWithValue("@sessionKey", session.SessionKey);
111+
cmd.Parameters.AddWithValue("@channel", session.Channel);
112+
cmd.Parameters.AddWithValue("@chatId", session.ChatId);
113+
cmd.Parameters.AddWithValue("@historyJson", historyJson);
114+
cmd.Parameters.AddWithValue("@summary", session.Summary ?? (object)DBNull.Value);
115+
cmd.Parameters.AddWithValue("@created", session.Created.ToString("O"));
116+
cmd.Parameters.AddWithValue("@lastActive", now);
117+
118+
await cmd.ExecuteNonQueryAsync(ct);
119+
}
120+
121+
public async Task<IReadOnlyList<SessionContext>> ListAsync(CancellationToken ct = default)
122+
{
123+
await using var cmd = _connection.CreateCommand();
124+
cmd.CommandText = """
125+
SELECT session_key, channel, chat_id, history_json, summary, created, last_active
126+
FROM sessions ORDER BY last_active DESC
127+
""";
128+
129+
var results = new List<SessionContext>();
130+
await using var reader = await cmd.ExecuteReaderAsync(ct);
131+
while (await reader.ReadAsync(ct))
132+
{
133+
results.Add(ReadSession(reader));
134+
}
135+
return results;
136+
}
137+
138+
public async Task DeleteAsync(string sessionKey, CancellationToken ct = default)
139+
{
140+
await using var cmd = _connection.CreateCommand();
141+
cmd.CommandText = "DELETE FROM sessions WHERE session_key = @sessionKey";
142+
cmd.Parameters.AddWithValue("@sessionKey", sessionKey);
143+
144+
await cmd.ExecuteNonQueryAsync(ct);
145+
}
146+
147+
/// <summary>
148+
/// Clears all sessions. Useful for testing.
149+
/// </summary>
150+
public async Task ClearAllAsync(CancellationToken ct = default)
151+
{
152+
await using var cmd = _connection.CreateCommand();
153+
cmd.CommandText = "DELETE FROM sessions";
154+
await cmd.ExecuteNonQueryAsync(ct);
155+
}
156+
157+
private SessionContext ReadSession(SqliteDataReader reader)
158+
{
159+
var sessionKey = reader.GetString(0);
160+
var channel = reader.GetString(1);
161+
var chatId = reader.GetString(2);
162+
var historyJson = reader.GetString(3);
163+
var summary = reader.IsDBNull(4) ? null : reader.GetString(4);
164+
var created = DateTimeOffset.Parse(reader.GetString(5));
165+
var lastActive = DateTimeOffset.Parse(reader.GetString(6));
166+
167+
var history = JsonSerializer.Deserialize<List<ClawSharp.Core.Providers.LlmMessage>>(historyJson, _jsonOptions) ?? [];
168+
169+
return new SessionContext
170+
{
171+
SessionKey = sessionKey,
172+
Channel = channel,
173+
ChatId = chatId,
174+
History = history,
175+
Summary = summary,
176+
Created = created,
177+
LastActive = lastActive
178+
};
179+
}
180+
181+
public async ValueTask DisposeAsync()
182+
{
183+
if (!_disposed)
184+
{
185+
await _connection.CloseAsync();
186+
await _connection.DisposeAsync();
187+
_disposed = true;
188+
}
189+
}
190+
}

src/ClawSharp.Core/Config/ProvidersConfig.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public class ProvidersConfig
77
public ProviderEntry? Anthropic { get; set; }
88
public ProviderEntry? OpenRouter { get; set; }
99
public ProviderEntry? Ollama { get; set; }
10+
public ProviderEntry? MiniMax { get; set; }
1011
public List<ProviderEntry> Compatible { get; set; } = [];
1112
}
1213

@@ -16,4 +17,5 @@ public class ProviderEntry
1617
public string? ApiKey { get; set; }
1718
public string? BaseUrl { get; set; }
1819
public string? DefaultModel { get; set; }
20+
public string? GroupId { get; set; }
1921
}

src/ClawSharp.Infrastructure/Http/HttpClientRegistration.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ public static IServiceCollection AddProviderHttpClients(this IServiceCollection
5959
client.Timeout = TimeSpan.FromSeconds(120);
6060
});
6161

62+
// MiniMax client - uses Anthropic-compatible API at api.minimax.io
63+
services.AddHttpClient("minimax", (sp, client) =>
64+
{
65+
var config = sp.GetRequiredService<ClawSharpConfig>();
66+
var providerConfig = config.Providers.MiniMax;
67+
var baseUrl = providerConfig?.BaseUrl ?? "https://api.minimax.io/v1";
68+
var apiKey = providerConfig?.ApiKey ?? "";
69+
var groupId = providerConfig?.GroupId ?? "";
70+
71+
client.BaseAddress = new Uri(baseUrl);
72+
if (!string.IsNullOrEmpty(apiKey))
73+
client.DefaultRequestHeaders.Authorization = new("Bearer", apiKey);
74+
if (!string.IsNullOrEmpty(groupId))
75+
client.DefaultRequestHeaders.Add("X-Group-Id", groupId);
76+
client.Timeout = TimeSpan.FromSeconds(120);
77+
});
78+
6279
return services;
6380
}
6481

0 commit comments

Comments
 (0)