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
5 changes: 4 additions & 1 deletion scripts/swap-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ start_daemon() {
echo "Starting netclaw.service via systemd..."
systemctl --user start netclaw.service
else
netclaw daemon start
# Detach stdio. The CLI's Process.Start inherits the parent's stdio
# handles into the daemon child, so without this redirect the script
# hangs forever waiting for the daemon to close the inherited pipes.
netclaw daemon start </dev/null >/dev/null 2>&1
fi
}

Expand Down
137 changes: 37 additions & 100 deletions src/Netclaw.Daemon/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ static async Task RunDaemonAsync(string[] args, DaemonRestartSignal restartSigna
// Eagerly resolve so StartedAt reflects daemon startup, not first request.
app.Services.GetRequiredService<DaemonStartClock>();

// Eagerly resolve so capability auto-detection (HF / OpenRouter / provider
// probes) runs at startup, not on first session creation — preserving the
// timing of the previous eager-resolution path while letting detection use
// the host's IModelCapabilityResolver chain and ILoggerFactory.
app.Services.GetRequiredService<ModelCapabilities>();

app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
Expand Down Expand Up @@ -374,9 +380,12 @@ static void ConfigureDaemonServices(
.Get<ModelSelection>() ?? new ModelSelection();
services.AddSingleton(models);

// Auto-detect model capabilities when not manually specified in config.
// Provider-first resolution: query the hosting provider (e.g. Ollama /api/show)
// before falling back to external oracles (OpenRouter, HuggingFace).
// Auto-detect model capabilities via the runtime IModelCapabilityResolver
// chain (registered further down). Lazy factory so detection runs against
// the real DI-wired resolvers with the host's logger — no temp HttpClient
// / LoggerFactory needed, and per-resolver Debug output is visible. The
// factory is invoked eagerly after Build() (see RunDaemonAsync) so timing
// matches a startup-bound resolution rather than first-session lazy hit.
var providers = configuration.GetSection("Providers")
.Get<Dictionary<string, ProviderEntry>>()
?? new() { ["local-ollama"] = new ProviderEntry() };
Expand All @@ -397,11 +406,32 @@ static void ConfigureDaemonServices(
? mainProvider?.ApiKey?.Value
: null;

var detected = ResolveStartupCapabilities(
models.Main.ModelId, daemonLogLevel, mainProviderType, ollamaEndpoint, openAiCompatibleEndpoint, openAiCompatibleApiKey);
services.AddSingleton<ModelCapabilities>(sp =>
{
var resolver = sp.GetRequiredService<IModelCapabilityResolver>();
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("Netclaw.Startup");

var detected = resolver.ResolveAsync(models.Main.ModelId, CancellationToken.None)
.GetAwaiter().GetResult();

if (detected is not null)
{
logger.LogInformation(
"Auto-detected model capabilities for {ModelId}: input={Input}, output={Output}, context_window={ContextWindow}",
models.Main.ModelId,
detected.InputModalities?.ToString() ?? "unknown",
detected.OutputModalities?.ToString() ?? "unknown",
detected.ContextWindowTokens?.ToString() ?? "unknown");
}
else
{
logger.LogInformation(
"Model {ModelId} not found in capability oracles; defaulting to text-only",
models.Main.ModelId);
}

var modelCapabilities = ModelCapabilityResolution.ResolveModelCapabilities(models, detected);
services.AddSingleton(modelCapabilities);
return ModelCapabilityResolution.ResolveModelCapabilities(models, detected);
});

// Session config: bind operator-facing settings from config section
var sessionConfig = SessionConfig.BindFromConfiguration(configuration.GetSection("Session"));
Expand Down Expand Up @@ -1050,99 +1080,6 @@ static void ConfigureDaemonServices(
}
}

/// <summary>
/// One-time capability detection at startup. Creates temporary HTTP resources
/// to query the hosting provider (Ollama) or OpenRouter public catalog before
/// the DI container is built.
/// Returns null if detection fails (caller falls back to text-only).
/// </summary>
static ResolvedModelCapabilities? ResolveStartupCapabilities(
string modelId, LogLevel logLevel, string? providerType, string? ollamaEndpoint, string? openAiCompatibleEndpoint, string? openAiCompatibleApiKey)
{
try
{
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var loggerFactory = LoggerFactory.Create(b => b.SetMinimumLevel(logLevel));
var logger = loggerFactory.CreateLogger("Netclaw.Startup");

// Provider-first: try Ollama /api/show when running against an Ollama backend
if (providerType?.Equals("ollama", StringComparison.OrdinalIgnoreCase) == true
&& ollamaEndpoint is not null)
{
var ollamaResolver = new OllamaCapabilityResolver(
httpClient, loggerFactory.CreateLogger<OllamaCapabilityResolver>(), ollamaEndpoint);
var ollamaResult = ollamaResolver.ResolveAsync(modelId, CancellationToken.None)
.GetAwaiter().GetResult();

if (ollamaResult is not null)
{
logger.LogInformation(
"Auto-detected model capabilities for {ModelId}: input={Input}, output={Output}, context_window={ContextWindow}",
modelId,
ollamaResult.InputModalities?.ToString() ?? "unknown",
ollamaResult.OutputModalities?.ToString() ?? "unknown",
ollamaResult.ContextWindowTokens?.ToString() ?? "unknown");
return ollamaResult;
}
}

if (providerType?.Equals("openai-compatible", StringComparison.OrdinalIgnoreCase) == true
&& openAiCompatibleEndpoint is not null)
{
var openAiCompatibleResolver = new OpenAiCompatibleCapabilityResolver(
httpClient,
loggerFactory.CreateLogger<OpenAiCompatibleCapabilityResolver>(),
openAiCompatibleEndpoint,
openAiCompatibleApiKey);
var openAiCompatibleResult = openAiCompatibleResolver.ResolveAsync(modelId, CancellationToken.None)
.GetAwaiter().GetResult();

if (openAiCompatibleResult is not null)
{
logger.LogInformation(
"Auto-detected model capabilities for {ModelId}: input={Input}, output={Output}, context_window={ContextWindow}",
modelId,
openAiCompatibleResult.InputModalities?.ToString() ?? "unknown",
openAiCompatibleResult.OutputModalities?.ToString() ?? "unknown",
openAiCompatibleResult.ContextWindowTokens?.ToString() ?? "unknown");
return openAiCompatibleResult;
}
}

// Fallback: OpenRouter public catalog (works for models from any provider)
var openRouterDescriptor = new OpenRouterDescriptor(httpClient);
var registry = new ProviderDescriptorRegistry([openRouterDescriptor]);
var resolver = new OpenRouterOracleResolver(
httpClient, loggerFactory.CreateLogger<OpenRouterOracleResolver>(), registry);

var result = resolver.ResolveAsync(modelId, CancellationToken.None)
.GetAwaiter().GetResult();

if (result is not null)
{
logger.LogInformation(
"Auto-detected model capabilities for {ModelId}: input={Input}, output={Output}, context_window={ContextWindow}",
modelId,
result.InputModalities?.ToString() ?? "unknown",
result.OutputModalities?.ToString() ?? "unknown",
result.ContextWindowTokens?.ToString() ?? "unknown");
}
else
{
logger.LogInformation(
"Model {ModelId} not found in capability oracles; defaulting to text-only",
modelId);
}

return result;
}
catch
{
// Startup capability detection is best-effort — don't crash the daemon
return null;
}
}

/// <summary>
/// Copies built-in system skills from the daemon's embedded resources into
/// build output as <c>BuiltInSkills/{skill-name}/SKILL.md</c> (with companion files).
Expand Down
Loading