Skip to content

Commit 2b52105

Browse files
fix: resolve all build errors and test failures (Phase 4 cleanup)
- Fix SkillLoader.cs: use correct Tomlyn 0.20 API (TomlTable/TomlTableArray, not TomlModel; TryGetValue not TryGetString; fix init-only property assignment; fix ToolResult positional record syntax) - Fix MigrateCommand.cs: remove Option.IsRequired (not in System.CommandLine 3.0 preview) - Fix UI tests (MudBlazor 8.x + bUnit): remove RenderTree.Add<MudPopoverProvider> (unsupported in v8); add RenderComponent<MudPopoverProvider>() in MemoryBrowserTests constructor instead - Fix ScheduleTests: use deterministic fixed time to eliminate race condition in CronScheduleExpr.IsDue_WhenRecentlyRun_ReturnsFalse All 495 tests across 10 test projects now pass.
1 parent 2794a33 commit 2b52105

39 files changed

Lines changed: 4592 additions & 31 deletions
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
using ClawSharp.Core.Channels;
2+
using ClawSharp.Core.Providers;
3+
using ClawSharp.Core.Tools;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace ClawSharp.Agent;
7+
8+
/// <summary>
9+
/// Request to spawn a sub-agent.
10+
/// </summary>
11+
public record SubAgentRequest(
12+
string Task,
13+
string? Model = null,
14+
string? SystemPrompt = null,
15+
int MaxIterations = 10
16+
);
17+
18+
/// <summary>
19+
/// Result from a sub-agent execution.
20+
/// </summary>
21+
public record SubAgentResult(
22+
string SessionId,
23+
bool Success,
24+
string? Content = null,
25+
string? Error = null,
26+
DateTimeOffset StartedAt = default,
27+
DateTimeOffset CompletedAt = default
28+
);
29+
30+
/// <summary>
31+
/// Factory for spawning isolated sub-agent instances for parallel task execution.
32+
/// Each sub-agent runs in its own session with its own AgentLoop.
33+
/// </summary>
34+
public sealed class SubAgentFactory
35+
{
36+
private readonly ILlmProvider _provider;
37+
private readonly IToolRegistry _tools;
38+
private readonly IMessageBus _messageBus;
39+
private readonly ILogger<SubAgentFactory> _logger;
40+
private readonly int _maxConcurrent;
41+
private int _activeCount;
42+
private readonly object _lock = new();
43+
private readonly List<SubAgentResult> _completedSessions = [];
44+
45+
/// <summary>Number of currently running sub-agents.</summary>
46+
public int ActiveCount => _activeCount;
47+
48+
/// <summary>Maximum concurrent sub-agents allowed.</summary>
49+
public int MaxConcurrent => _maxConcurrent;
50+
51+
/// <summary>History of completed sub-agent sessions.</summary>
52+
public IReadOnlyList<SubAgentResult> CompletedSessions
53+
{
54+
get { lock (_lock) return [.. _completedSessions]; }
55+
}
56+
57+
public SubAgentFactory(
58+
ILlmProvider provider,
59+
IToolRegistry tools,
60+
IMessageBus messageBus,
61+
ILogger<SubAgentFactory> logger,
62+
int maxConcurrent = 5)
63+
{
64+
_provider = provider;
65+
_tools = tools;
66+
_messageBus = messageBus;
67+
_logger = logger;
68+
_maxConcurrent = maxConcurrent;
69+
}
70+
71+
/// <summary>
72+
/// Spawn a sub-agent to execute a task.
73+
/// </summary>
74+
public async Task<SubAgentResult> SpawnAsync(SubAgentRequest request, CancellationToken ct = default)
75+
{
76+
ct.ThrowIfCancellationRequested();
77+
78+
lock (_lock)
79+
{
80+
if (_activeCount >= _maxConcurrent)
81+
throw new InvalidOperationException(
82+
$"Max concurrent sub-agents ({_maxConcurrent}) reached. Wait for existing agents to complete.");
83+
_activeCount++;
84+
}
85+
86+
var sessionId = $"subagent:{Guid.NewGuid():N}";
87+
var startedAt = DateTimeOffset.UtcNow;
88+
89+
try
90+
{
91+
_logger.LogInformation("Spawning sub-agent {SessionId} for task: {Task}",
92+
sessionId, Truncate(request.Task, 100));
93+
94+
var agentLogger = new SubAgentLogger<AgentLoop>(_logger);
95+
96+
var agentLoop = new AgentLoop(
97+
_provider,
98+
_tools,
99+
_messageBus,
100+
agentLogger,
101+
request.MaxIterations);
102+
103+
var messages = BuildMessages(request);
104+
var agentRequest = new AgentLoop.AgentRequest(
105+
request.Model ?? "default",
106+
messages);
107+
108+
var agentResult = await agentLoop.RunAsync(agentRequest, ct);
109+
110+
var result = new SubAgentResult(
111+
sessionId,
112+
Success: true,
113+
Content: agentResult.Content,
114+
StartedAt: startedAt,
115+
CompletedAt: DateTimeOffset.UtcNow);
116+
117+
lock (_lock) _completedSessions.Add(result);
118+
_logger.LogInformation("Sub-agent {SessionId} completed successfully", sessionId);
119+
return result;
120+
}
121+
catch (OperationCanceledException)
122+
{
123+
throw;
124+
}
125+
catch (Exception ex)
126+
{
127+
_logger.LogError(ex, "Sub-agent {SessionId} failed", sessionId);
128+
var result = new SubAgentResult(
129+
sessionId,
130+
Success: false,
131+
Error: ex.Message,
132+
StartedAt: startedAt,
133+
CompletedAt: DateTimeOffset.UtcNow);
134+
135+
lock (_lock) _completedSessions.Add(result);
136+
return result;
137+
}
138+
finally
139+
{
140+
lock (_lock) _activeCount--;
141+
}
142+
}
143+
144+
private static List<LlmMessage> BuildMessages(SubAgentRequest request)
145+
{
146+
var messages = new List<LlmMessage>();
147+
148+
var systemPrompt = request.SystemPrompt ?? "You are a sub-agent. Complete the assigned task concisely.";
149+
messages.Add(new LlmMessage("system", systemPrompt));
150+
messages.Add(new LlmMessage("user", request.Task));
151+
152+
return messages;
153+
}
154+
155+
private static string Truncate(string text, int maxLength) =>
156+
text.Length <= maxLength ? text : text[..maxLength] + "...";
157+
158+
// Simple logger wrapper that forwards to parent logger
159+
private sealed class SubAgentLogger<T>(ILogger parentLogger) : ILogger<T>
160+
{
161+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => parentLogger.BeginScope(state);
162+
public bool IsEnabled(LogLevel logLevel) => parentLogger.IsEnabled(logLevel);
163+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
164+
=> parentLogger.Log(logLevel, eventId, state, exception, formatter);
165+
}
166+
}

src/ClawSharp.Cli/CliEntryPoint.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public static RootCommand CreateRootCommand()
1414
rootCommand.Add(new AgentCommand());
1515
rootCommand.Add(new GatewayCommand());
1616
rootCommand.Add(new OnboardCommand());
17+
rootCommand.Add(new ServiceCommand());
1718

1819
var configOption = new Option<string>("--config") { Description = "Path to config.toml" };
1920
var verboseOption = new Option<bool>("--verbose") { Description = "Enable verbose logging" };
Lines changed: 155 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,184 @@
11
using System.CommandLine;
2+
using System.Text.Json;
23

34
namespace ClawSharp.Cli.Commands;
45

6+
/// <summary>
7+
/// Represents a single diagnostic check result.
8+
/// </summary>
9+
public record DoctorCheck(string Name, bool Pass, string Detail);
10+
11+
/// <summary>
12+
/// Comprehensive system diagnostics command.
13+
/// </summary>
514
public class DoctorCommand : Command
615
{
7-
public DoctorCommand() : base("doctor", "Run diagnostics")
16+
public DoctorCommand() : base("doctor", "Run comprehensive system diagnostics")
817
{
9-
SetAction(_ => Execute());
18+
var fixOption = new Option<bool>("--fix");
19+
fixOption.Description = "Attempt to auto-fix issues (create missing directories, etc.)";
20+
21+
var jsonOption = new Option<bool>("--json");
22+
jsonOption.Description = "Output results as JSON";
23+
24+
Options.Add(fixOption);
25+
Options.Add(jsonOption);
26+
27+
SetAction(ctx =>
28+
{
29+
var fix = ctx.GetValue(fixOption);
30+
var json = ctx.GetValue(jsonOption);
31+
return Execute(fix, json);
32+
});
1033
}
1134

12-
private static void Execute()
35+
private static int Execute(bool fix, bool json)
1336
{
14-
Console.WriteLine(" ClawSharp Doctor");
15-
Console.WriteLine(" ================");
16-
17-
var checks = new List<(string Name, bool Pass, string Detail)>();
37+
var checks = new List<DoctorCheck>();
1838

19-
var homePath = Path.Combine(
20-
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".clawsharp");
39+
// Determine base paths
40+
var dataDir = Environment.GetEnvironmentVariable("CLAWSHARP_DATA_DIR")
41+
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".clawsharp");
2142

2243
var configPath = Environment.GetEnvironmentVariable("CLAWSHARP_CONFIG_PATH")
23-
?? Path.Combine(homePath, "config.toml");
24-
checks.Add(("Config file", File.Exists(configPath), configPath));
44+
?? Path.Combine(dataDir, "config.toml");
45+
46+
var workspacePath = Path.Combine(dataDir, "workspace");
47+
var memoryPath = Path.Combine(workspacePath, "memory");
48+
var skillsPath = Path.Combine(workspacePath, "skills");
49+
50+
// Check: Data directory
51+
var dataExists = Directory.Exists(dataDir);
52+
if (!dataExists && fix)
53+
{
54+
Directory.CreateDirectory(dataDir);
55+
dataExists = true;
56+
}
57+
checks.Add(new DoctorCheck("Data directory", dataExists, dataDir));
2558

26-
checks.Add(("Data directory", Directory.Exists(homePath), homePath));
59+
// Check: Config file
60+
var configExists = File.Exists(configPath);
61+
checks.Add(new DoctorCheck("Config file", configExists, configPath));
2762

28-
var workspacePath = Path.Combine(homePath, "workspace");
29-
checks.Add(("Workspace directory", Directory.Exists(workspacePath), workspacePath));
63+
// Check: Workspace directory
64+
var workspaceExists = Directory.Exists(workspacePath);
65+
if (!workspaceExists && fix)
66+
{
67+
Directory.CreateDirectory(workspacePath);
68+
workspaceExists = true;
69+
}
70+
checks.Add(new DoctorCheck("Workspace directory", workspaceExists, workspacePath));
71+
72+
// Check: Memory directory
73+
var memoryExists = Directory.Exists(memoryPath);
74+
if (!memoryExists && fix)
75+
{
76+
Directory.CreateDirectory(memoryPath);
77+
memoryExists = true;
78+
}
79+
checks.Add(new DoctorCheck("Memory directory", memoryExists, memoryPath));
80+
81+
// Check: Skills directory
82+
var skillsExists = Directory.Exists(skillsPath);
83+
if (!skillsExists && fix)
84+
{
85+
Directory.CreateDirectory(skillsPath);
86+
skillsExists = true;
87+
}
88+
checks.Add(new DoctorCheck("Skills directory", skillsExists, skillsPath));
3089

31-
// SQLite check
90+
// Check: SQLite availability
91+
bool sqliteOk;
92+
string sqliteDetail;
3293
try
3394
{
3495
using var conn = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:");
3596
conn.Open();
36-
checks.Add(("SQLite", true, "Available"));
97+
sqliteOk = true;
98+
sqliteDetail = "Available";
3799
}
38100
catch (Exception ex)
39101
{
40-
checks.Add(("SQLite", false, ex.Message));
102+
sqliteOk = false;
103+
sqliteDetail = ex.Message;
41104
}
105+
checks.Add(new DoctorCheck("SQLite", sqliteOk, sqliteDetail));
42106

43-
foreach (var (name, pass, detail) in checks)
107+
// Check: Disk space
108+
bool diskOk;
109+
string diskDetail;
110+
try
111+
{
112+
var drive = new DriveInfo(Path.GetPathRoot(dataDir) ?? "/");
113+
var freeGb = drive.AvailableFreeSpace / (1024.0 * 1024 * 1024);
114+
diskOk = freeGb > 1.0; // At least 1 GB free
115+
diskDetail = $"{freeGb:F2} GB free";
116+
}
117+
catch (Exception ex)
44118
{
45-
var icon = pass ? "✅" : "❌";
46-
Console.WriteLine($" {icon} {name}: {detail}");
119+
diskOk = true; // Can't check, assume OK
120+
diskDetail = $"Unable to check: {ex.Message}";
47121
}
122+
checks.Add(new DoctorCheck("Disk space", diskOk, diskDetail));
123+
124+
// Output results
125+
if (json)
126+
{
127+
OutputJson(checks);
128+
}
129+
else
130+
{
131+
OutputText(checks);
132+
}
133+
134+
// Return exit code based on critical failures
135+
var criticalFailures = checks.Count(c => !c.Pass && IsCritical(c.Name));
136+
return criticalFailures > 0 ? 1 : 0;
137+
}
138+
139+
private static bool IsCritical(string checkName)
140+
{
141+
// Data directory and SQLite are critical
142+
return checkName is "Data directory" or "SQLite";
143+
}
144+
145+
private static void OutputJson(List<DoctorCheck> checks)
146+
{
147+
var result = new
148+
{
149+
checks = checks.Select(c => new
150+
{
151+
name = c.Name,
152+
pass = c.Pass,
153+
detail = c.Detail
154+
}).ToArray(),
155+
summary = new
156+
{
157+
total = checks.Count,
158+
passed = checks.Count(c => c.Pass),
159+
failed = checks.Count(c => !c.Pass)
160+
}
161+
};
162+
163+
var options = new JsonSerializerOptions { WriteIndented = true };
164+
Console.WriteLine(JsonSerializer.Serialize(result, options));
165+
}
166+
167+
private static void OutputText(List<DoctorCheck> checks)
168+
{
169+
Console.WriteLine(" ClawSharp Doctor");
170+
Console.WriteLine(" ================");
171+
Console.WriteLine();
172+
173+
foreach (var check in checks)
174+
{
175+
var icon = check.Pass ? "✅" : "❌";
176+
Console.WriteLine($" {icon} {check.Name}: {check.Detail}");
177+
}
178+
179+
Console.WriteLine();
180+
var passed = checks.Count(c => c.Pass);
181+
var total = checks.Count;
182+
Console.WriteLine($" Summary: {passed}/{total} checks passed");
48183
}
49184
}

0 commit comments

Comments
 (0)