Skip to content

Commit 9aa3933

Browse files
feat: implement HTTP Gateway with Kestrel (Task 3.5)
- Add Kestrel-based HTTP gateway with minimal API endpoints: - GET /health - Health check endpoint - POST /v1/agent - Chat with agent - GET /v1/sessions - List sessions - POST /v1/messages - Send message to channel - Gateway serves on configurable port (default 8080) - Integrates with AgentLoop for agent functionality - Uses SQLite session manager for conversation history - Includes test project with WebApplicationFactory tests TDD approach followed: - Tests written first (Red phase) - Implementation made tests pass (Green phase) - All 10 tests passing
1 parent 43a4656 commit 9aa3933

6 files changed

Lines changed: 501 additions & 21 deletions

File tree

ClawSharp.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
</Folder>
1414
<Folder Name="/tests/">
1515
<Project Path="tests/ClawSharp.Agent.Tests/ClawSharp.Agent.Tests.csproj" />
16+
<Project Path="tests/ClawSharp.Channels.Tests/ClawSharp.Channels.Tests.csproj" />
1617
<Project Path="tests/ClawSharp.Cli.Tests/ClawSharp.Cli.Tests.csproj" />
1718
<Project Path="tests/ClawSharp.Core.Tests/ClawSharp.Core.Tests.csproj" />
19+
<Project Path="tests/ClawSharp.Gateway.Tests/ClawSharp.Gateway.Tests.csproj" />
1820
<Project Path="tests/ClawSharp.Infrastructure.Tests/ClawSharp.Infrastructure.Tests.csproj" />
1921
<Project Path="tests/ClawSharp.Memory.Tests/ClawSharp.Memory.Tests.csproj" />
2022
<Project Path="tests/ClawSharp.Providers.Tests/ClawSharp.Providers.Tests.csproj" />

src/ClawSharp.Gateway/ClawSharp.Gateway.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
<ItemGroup>
1313
<ProjectReference Include="..\ClawSharp.Agent\ClawSharp.Agent.csproj" />
14+
<ProjectReference Include="..\ClawSharp.Core\ClawSharp.Core.csproj" />
1415
<ProjectReference Include="..\ClawSharp.Infrastructure\ClawSharp.Infrastructure.csproj" />
1516
</ItemGroup>
1617

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
using ClawSharp.Agent;
2+
using ClawSharp.Core.Channels;
3+
using ClawSharp.Core.Config;
4+
using ClawSharp.Core.Providers;
5+
using ClawSharp.Core.Sessions;
6+
using ClawSharp.Infrastructure.Messaging;
7+
using Microsoft.AspNetCore.Mvc;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace ClawSharp.Gateway.Endpoints;
11+
12+
/// <summary>
13+
/// Request model for chat endpoint.
14+
/// </summary>
15+
public record ChatRequest(
16+
string Message,
17+
string? SessionKey = null,
18+
string? Model = null
19+
);
20+
21+
/// <summary>
22+
/// Response model for chat endpoint.
23+
/// </summary>
24+
public record ChatResponse(
25+
string Response,
26+
string? SessionKey,
27+
IReadOnlyList<ToolExecutionInfo>? ToolExecutions = null
28+
);
29+
30+
/// <summary>
31+
/// Tool execution info for response.
32+
/// </summary>
33+
public record ToolExecutionInfo(
34+
string ToolCallId,
35+
string ToolName,
36+
string ArgumentsJson,
37+
bool Success,
38+
string? Output,
39+
string? Error
40+
);
41+
42+
/// <summary>
43+
/// Request model for messages endpoint.
44+
/// </summary>
45+
public record MessageRequest(
46+
string Channel,
47+
string ChatId,
48+
string Content
49+
);
50+
51+
/// <summary>
52+
/// Minimal API endpoints for the ClawSharp HTTP gateway.
53+
/// </summary>
54+
public static class GatewayEndpoints
55+
{
56+
public static void MapGatewayEndpoints(this WebApplication app)
57+
{
58+
var logger = app.Logger;
59+
60+
// Health check endpoint
61+
app.MapGet("/health", () => new { status = "ok", timestamp = DateTimeOffset.UtcNow.ToString("O") })
62+
.WithName("Health")
63+
.WithTags("Health");
64+
65+
// Agent chat endpoint - POST /v1/agent
66+
app.MapPost("/v1/agent", async (
67+
[FromBody] ChatRequest request,
68+
AgentLoop agentLoop,
69+
ISessionManager sessionManager,
70+
ClawSharpConfig config,
71+
ILogger<AgentLoop> agentLogger,
72+
CancellationToken ct) =>
73+
{
74+
if (string.IsNullOrWhiteSpace(request.Message))
75+
{
76+
return Results.BadRequest(new { error = "Message is required" });
77+
}
78+
79+
// Get or create session
80+
var sessionKey = request.SessionKey ?? $"web-{Guid.NewGuid():N}";
81+
var session = await sessionManager.GetOrCreateAsync(
82+
sessionKey,
83+
"web",
84+
sessionKey,
85+
ct);
86+
87+
// Add user message to history
88+
session.History.Add(new LlmMessage("user", request.Message));
89+
90+
// Run agent loop
91+
var agentRequest = new AgentLoop.AgentRequest(
92+
request.Model ?? config.DefaultModel ?? "default",
93+
session.History
94+
);
95+
96+
var result = await agentLoop.RunAsync(agentRequest, ct);
97+
98+
// Add assistant response to history
99+
session.History.Add(new LlmMessage("assistant", result.Content));
100+
await sessionManager.SaveAsync(session, ct);
101+
102+
// Map tool executions
103+
var toolInfos = result.ToolExecutions.Select(t => new ToolExecutionInfo(
104+
t.ToolCallId,
105+
t.ToolName,
106+
t.ArgumentsJson,
107+
t.Result.Success,
108+
t.Result.Output,
109+
t.Result.Error
110+
)).ToList();
111+
112+
return Results.Ok(new ChatResponse(
113+
result.Content,
114+
sessionKey,
115+
toolInfos
116+
));
117+
})
118+
.WithName("Chat")
119+
.WithTags("Agent");
120+
121+
// Sessions endpoint - GET /v1/sessions
122+
app.MapGet("/v1/sessions", async (
123+
ISessionManager sessionManager,
124+
CancellationToken ct) =>
125+
{
126+
var sessions = await sessionManager.ListAsync(ct);
127+
return Results.Ok(sessions.Select(s => new
128+
{
129+
s.SessionKey,
130+
s.Channel,
131+
s.ChatId,
132+
s.Summary,
133+
s.Created,
134+
s.LastActive,
135+
MessageCount = s.History.Count
136+
}));
137+
})
138+
.WithName("Sessions")
139+
.WithTags("Sessions");
140+
141+
// Messages endpoint - POST /v1/messages
142+
app.MapPost("/v1/messages", async (
143+
[FromBody] MessageRequest request,
144+
IMessageBus messageBus,
145+
ClawSharpConfig config,
146+
CancellationToken ct) =>
147+
{
148+
if (string.IsNullOrWhiteSpace(request.Content))
149+
{
150+
return Results.BadRequest(new { error = "Content is required" });
151+
}
152+
153+
if (string.IsNullOrWhiteSpace(request.Channel))
154+
{
155+
return Results.BadRequest(new { error = "Channel is required" });
156+
}
157+
158+
if (string.IsNullOrWhiteSpace(request.ChatId))
159+
{
160+
return Results.BadRequest(new { error = "ChatId is required" });
161+
}
162+
163+
// Publish message to the message bus
164+
var channelMessage = new ChannelMessage(
165+
Guid.NewGuid().ToString(),
166+
"web",
167+
request.Content,
168+
request.Channel,
169+
request.ChatId,
170+
DateTimeOffset.UtcNow
171+
);
172+
173+
await messageBus.PublishAsync(channelMessage, ct);
174+
175+
return Results.Accepted();
176+
})
177+
.WithName("Messages")
178+
.WithTags("Messages");
179+
}
180+
}

src/ClawSharp.Gateway/Program.cs

Lines changed: 132 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,59 @@
1+
using ClawSharp.Agent;
2+
using ClawSharp.Core.Channels;
3+
using ClawSharp.Core.Config;
4+
using ClawSharp.Core.Providers;
5+
using ClawSharp.Core.Sessions;
6+
using ClawSharp.Core.Tools;
7+
using ClawSharp.Infrastructure;
8+
using ClawSharp.Infrastructure.Messaging;
9+
using ClawSharp.Gateway.Endpoints;
10+
using Microsoft.AspNetCore.Mvc;
11+
112
var builder = WebApplication.CreateBuilder(args);
213

314
// Add services to the container.
4-
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
15+
builder.Services.AddEndpointsApiExplorer();
516
builder.Services.AddOpenApi();
617

18+
// Add ClawSharp services
19+
var config = new ClawSharpConfig
20+
{
21+
DataDir = Path.Combine(builder.Environment.ContentRootPath, "data"),
22+
WorkspaceDir = Path.Combine(builder.Environment.ContentRootPath, "workspace"),
23+
DefaultProvider = "test",
24+
DefaultModel = "test-model"
25+
};
26+
builder.Services.AddSingleton(config);
27+
28+
// Add in-memory message bus
29+
builder.Services.AddSingleton<IMessageBus, InProcessMessageBus>();
30+
31+
// Add session manager (in-memory for now)
32+
builder.Services.AddSingleton<ISessionManager>(sp =>
33+
{
34+
var logger = sp.GetRequiredService<ILogger<SqliteSessionManager>>();
35+
return new SqliteSessionManager(":memory:");
36+
});
37+
38+
// Add a mock LLM provider for testing
39+
builder.Services.AddSingleton<ILlmProvider>(sp =>
40+
{
41+
var logger = sp.GetRequiredService<ILogger<TestLlmProvider>>();
42+
return new TestLlmProvider(logger);
43+
});
44+
45+
// Add tool registry (empty for now)
46+
builder.Services.AddSingleton<IToolRegistry>(sp =>
47+
{
48+
return new InMemoryToolRegistry();
49+
});
50+
51+
// Add agent loop
52+
builder.Services.AddSingleton<AgentLoop>();
53+
54+
// Add channel collection (empty for now)
55+
builder.Services.AddSingleton<IReadOnlyList<IChannel>>(sp => []);
56+
757
var app = builder.Build();
858

959
// Configure the HTTP request pipeline.
@@ -12,30 +62,91 @@
1262
app.MapOpenApi();
1363
}
1464

15-
app.UseHttpsRedirection();
65+
// Remove HTTPS redirection for local development
66+
// app.UseHttpsRedirection();
1667

17-
var summaries = new[]
18-
{
19-
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
20-
};
68+
// Map Gateway endpoints
69+
app.MapGatewayEndpoints();
70+
71+
app.Run();
2172

22-
app.MapGet("/weatherforecast", () =>
73+
/// <summary>
74+
/// Test LLM provider that returns mock responses.
75+
/// </summary>
76+
public class TestLlmProvider : ILlmProvider
2377
{
24-
var forecast = Enumerable.Range(1, 5).Select(index =>
25-
new WeatherForecast
26-
(
27-
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
28-
Random.Shared.Next(-20, 55),
29-
summaries[Random.Shared.Next(summaries.Length)]
30-
))
31-
.ToArray();
32-
return forecast;
33-
})
34-
.WithName("GetWeatherForecast");
78+
private readonly ILogger<TestLlmProvider> _logger;
3579

36-
app.Run();
80+
public TestLlmProvider(ILogger<TestLlmProvider> logger)
81+
{
82+
_logger = logger;
83+
}
84+
85+
public string Name => "test";
86+
87+
public async Task<bool> IsAvailableAsync(CancellationToken ct = default)
88+
{
89+
return await Task.FromResult(true);
90+
}
91+
92+
public async Task<IReadOnlyList<string>> ListModelsAsync(CancellationToken ct = default)
93+
{
94+
return await Task.FromResult<IReadOnlyList<string>>(["test-model"]);
95+
}
3796

38-
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
97+
public Task<LlmResponse> CompleteAsync(LlmRequest request, CancellationToken ct = default)
98+
{
99+
_logger.LogInformation("Test provider received request with {MessageCount} messages", request.Messages.Count);
100+
101+
var lastMessage = request.Messages.LastOrDefault();
102+
var response = lastMessage?.Content ?? "";
103+
104+
return Task.FromResult(new LlmResponse(
105+
Content: $"Echo: {response}",
106+
ToolCalls: [],
107+
FinishReason: "stop",
108+
Usage: null
109+
));
110+
}
111+
112+
public async IAsyncEnumerable<LlmStreamChunk> StreamAsync(LlmRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
113+
{
114+
var lastMessage = request.Messages.LastOrDefault();
115+
var response = lastMessage?.Content ?? "";
116+
117+
yield return new LlmStreamChunk(
118+
ContentDelta: $"Echo: {response}",
119+
ToolCallDelta: null,
120+
FinishReason: "stop",
121+
Usage: null
122+
);
123+
}
124+
}
125+
126+
/// <summary>
127+
/// In-memory tool registry for testing.
128+
/// </summary>
129+
public class InMemoryToolRegistry : IToolRegistry
39130
{
40-
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
131+
private readonly Dictionary<string, ITool> _tools = new();
132+
133+
public void Register(ITool tool)
134+
{
135+
_tools[tool.Name] = tool;
136+
}
137+
138+
public ITool? Get(string name)
139+
{
140+
return _tools.GetValueOrDefault(name);
141+
}
142+
143+
public IReadOnlyList<ITool> GetAll()
144+
{
145+
return _tools.Values.ToList();
146+
}
147+
148+
public IReadOnlyList<ClawSharp.Core.Tools.ToolSpec> GetSpecifications()
149+
{
150+
return [];
151+
}
41152
}

0 commit comments

Comments
 (0)