diff --git a/scripts/swap-daemon.sh b/scripts/swap-daemon.sh index 0182a21e6..060880abb 100755 --- a/scripts/swap-daemon.sh +++ b/scripts/swap-daemon.sh @@ -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 2>&1 fi } diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index 7f19587d6..2bc907cc4 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -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(); + // 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(); + app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); @@ -374,9 +380,12 @@ static void ConfigureDaemonServices( .Get() ?? 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>() ?? new() { ["local-ollama"] = new ProviderEntry() }; @@ -397,11 +406,32 @@ static void ConfigureDaemonServices( ? mainProvider?.ApiKey?.Value : null; - var detected = ResolveStartupCapabilities( - models.Main.ModelId, daemonLogLevel, mainProviderType, ollamaEndpoint, openAiCompatibleEndpoint, openAiCompatibleApiKey); + services.AddSingleton(sp => + { + var resolver = sp.GetRequiredService(); + var logger = sp.GetRequiredService().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")); @@ -1050,99 +1080,6 @@ static void ConfigureDaemonServices( } } -/// -/// 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). -/// -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(), 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(), - 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(), 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; - } -} - /// /// Copies built-in system skills from the daemon's embedded resources into /// build output as BuiltInSkills/{skill-name}/SKILL.md (with companion files).