Skip to content

Commit 74a8db8

Browse files
authored
fix(runtime): defer provider resolution until agent use (#242)
1 parent 6b1c31e commit 74a8db8

8 files changed

Lines changed: 259 additions & 112 deletions

File tree

specs/architecture/runtime-module-boundaries.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ open databases, bind ports, start processes, or run background loops.
120120
The owning Generic Host controls the lifecycle:
121121

122122
1. Registration adds Runtime, selected providers, optional features, and optional AppServer.
123-
2. Host startup validates configuration and required capabilities.
123+
2. Host startup validates provider-independent configuration and required host capabilities.
124124
3. Runtime provisions the workspace, initializes persistent services, starts owned background
125125
services, and becomes ready.
126126
4. Requests use the single Core service graph owned by that host.
@@ -131,7 +131,11 @@ A startup failure never exposes a partially ready Runtime. Resources acquired be
131131
released through the host startup-failure path. Stop and disposal are safe when invoked through the
132132
normal Generic Host lifecycle.
133133

134-
Missing providers, invalid workspace configuration, and conflicting registrations fail before the
134+
Missing model providers, model preferences, and provider implementations are validated when an
135+
operation first builds an agent or otherwise requires model access. A Runtime without a resolvable
136+
model remains available for provider-independent work such as config-schema inspection, agent
137+
profile, skill, and command discovery, thread indexing, and recovery export. Invalid
138+
provider-independent workspace configuration and conflicting registrations still fail before the
135139
Runtime accepts work. Transport failures follow the AppServer protocol contract and do not expose
136140
internal exception types as wire contracts.
137141

src/DotCraft.Core/Agents/Runtime/AgentFactory.cs

Lines changed: 17 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ namespace DotCraft.Agents;
2929
public sealed class AgentFactory : IAsyncDisposable
3030
{
3131
private readonly AppConfig _config;
32-
private readonly IChatClient _chatClient;
32+
private readonly IChatClient? _chatClientOverride;
3333
private readonly ConcurrentDictionary<string, TokenTracker> _tokenTrackers = new();
3434
private readonly ConcurrentDictionary<CompactionPipelineKey, CompactionPipeline> _compactionPipelines = new();
3535
private readonly TraceCollector? _traceCollector;
@@ -42,6 +42,8 @@ public sealed class AgentFactory : IAsyncDisposable
4242
private readonly IRemoteToolHostClient? _remoteToolHostClient;
4343
private readonly ChatClientRegistry _chatClientRegistry;
4444
private readonly IChatClient? _compactionChatClientOverride;
45+
private CompactionPipeline? _defaultCompactionPipeline;
46+
private IMemoryConsolidator? _defaultConsolidator;
4547
private static readonly ConcurrentDictionary<MethodInfo, bool> StreamArgumentsOptOutCache = new();
4648
private readonly CustomCommandLoader? _customCommandLoader;
4749
private readonly PlanStore? _planStore;
@@ -92,6 +94,7 @@ public AgentFactory(
9294
_onConsolidatorStatus = onConsolidatorStatus;
9395
_memoryConsolidatorOverride = memoryConsolidator;
9496
_compactionChatClientOverride = compactionChatClient;
97+
_chatClientOverride = chatClient;
9598
_toolDispatcher = toolDispatcher ?? new ToolDispatcher();
9699
_remoteToolHostClient = remoteToolHostClient;
97100
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
@@ -101,67 +104,11 @@ public AgentFactory(
101104
?? runtimeContext?.ChatClientRegistry
102105
?? new ChatClientRegistry(new ModelProviderRegistry([]));
103106

104-
var mainRuntime = _chatClientRegistry.ResolveMainRuntime(config);
105-
var mainModel = mainRuntime.Model;
106-
var mainCompactionConfig = ModelCatalog.ResolveCompactionConfig(config, mainModel);
107-
_chatClient = chatClient ?? _chatClientRegistry.GetChatClient(mainRuntime);
108-
var consolidationRuntime = _chatClientRegistry.ResolveConsolidationRuntime(
109-
config,
110-
mainRuntime.ProviderId,
111-
mainModel);
112-
var maintenanceMainChatClient = ProviderChatClientAdapters.CreateRequestAdaptedClient(
113-
_chatClient,
114-
config,
115-
mainRuntime,
116-
useDefaultReasoning: false);
117-
var consolidationChatClient = chatClient ?? _chatClientRegistry.GetChatClient(consolidationRuntime);
118-
var legacyConsolidator = new MemoryConsolidator(
119-
ProviderChatClientAdapters.CreateRequestAdaptedClient(
120-
consolidationChatClient,
121-
config,
122-
consolidationRuntime,
123-
useDefaultReasoning: false),
124-
memoryStore,
125-
onConsolidatorStatus);
126-
Consolidator = _memoryConsolidatorOverride
127-
?? new MemoryForkConsolidator(
128-
new MaintenanceForkRunner(
129-
maintenanceMainChatClient,
130-
cacheOptions: new MaintenanceForkCacheOptions(
131-
mainRuntime.Protocol,
132-
_config.PromptCaching,
133-
mainModel)),
134-
legacyConsolidator,
135-
memoryStore,
136-
mainModel,
137-
consolidationRuntime.Model,
138-
mainCompactionConfig.BlockingLimit(),
139-
workspacePath);
140-
141-
CompactionPipeline = new CompactionPipeline(
142-
mainCompactionConfig,
143-
ProviderChatClientAdapters.CreateRequestAdaptedClient(
144-
_compactionChatClientOverride ?? _chatClient,
145-
config,
146-
mainRuntime,
147-
useDefaultReasoning: false),
148-
_traceCollector,
149-
new MaintenanceForkCacheOptions(
150-
mainRuntime.Protocol,
151-
_config.PromptCaching,
152-
mainModel),
153-
runtimeContext?.Contributions,
154-
logger: _logger);
155-
156-
// Build the source-neutral runtime context.
157107
_runtimeContext = runtimeContext ?? new AgentRuntimeContext
158108
{
159109
Config = config,
160-
ChatClient = _chatClient,
110+
ChatClient = chatClient,
161111
ChatClientRegistry = _chatClientRegistry,
162-
EffectiveProviderId = mainRuntime.ProviderId,
163-
EffectiveProviderProtocol = mainRuntime.Protocol,
164-
EffectiveMainModel = mainModel,
165112
WorkspacePath = workspacePath,
166113
BotPath = dotcraftPath,
167114
MemoryStore = memoryStore,
@@ -260,7 +207,8 @@ await source.ReleaseRetiredThreadResourcesAsync(threadId, cancellationToken)
260207
/// <summary>
261208
/// Gets the layered context-compaction pipeline (auto / reactive / manual).
262209
/// </summary>
263-
public CompactionPipeline CompactionPipeline { get; }
210+
public CompactionPipeline CompactionPipeline =>
211+
_defaultCompactionPipeline ??= GetCompactionPipeline(string.Empty);
264212

265213
/// <summary>
266214
/// Gets the context-compaction pipeline for a thread's effective main model.
@@ -289,6 +237,7 @@ public CompactionPipeline GetCompactionPipeline(
289237
var (factory, resolvedConfig, config) = state;
290238
var runtime = pipelineKey.ToRuntime();
291239
var baseChatClient = factory._compactionChatClientOverride
240+
?? factory._chatClientOverride
292241
?? factory._chatClientRegistry.GetChatClient(runtime);
293242
return new CompactionPipeline(
294243
resolvedConfig,
@@ -327,7 +276,7 @@ public CompactionPipeline GetCompactionPipeline(
327276
mainRuntime.Model);
328277
var fallback = new MemoryConsolidator(
329278
ProviderChatClientAdapters.CreateRequestAdaptedClient(
330-
_chatClientRegistry.GetChatClient(consolidationRuntime),
279+
_chatClientOverride ?? _chatClientRegistry.GetChatClient(consolidationRuntime),
331280
config,
332281
consolidationRuntime,
333282
useDefaultReasoning: false),
@@ -337,7 +286,7 @@ public CompactionPipeline GetCompactionPipeline(
337286
return new MemoryForkConsolidator(
338287
new MaintenanceForkRunner(
339288
ProviderChatClientAdapters.CreateRequestAdaptedClient(
340-
_chatClientRegistry.GetChatClient(mainRuntime),
289+
_chatClientOverride ?? _chatClientRegistry.GetChatClient(mainRuntime),
341290
config,
342291
mainRuntime,
343292
useDefaultReasoning: false),
@@ -362,7 +311,8 @@ public CompactionPipeline GetCompactionPipeline(
362311
/// Session Core drives consolidation independently from context
363312
/// compaction, using completed thread history as input.
364313
/// </summary>
365-
public IMemoryConsolidator? Consolidator { get; }
314+
public IMemoryConsolidator? Consolidator =>
315+
_defaultConsolidator ??= CreateConsolidatorForRuntime(_config, null, null);
366316

367317
/// <summary>
368318
/// Gets or creates a token tracker for the specified session.
@@ -386,7 +336,7 @@ public TokenTracker GetOrCreateTokenTracker(string sessionKey)
386336
public void RemoveTokenTracker(string sessionKey)
387337
{
388338
_tokenTrackers.TryRemove(sessionKey, out _);
389-
CompactionPipeline.Forget(sessionKey);
339+
_defaultCompactionPipeline?.Forget(sessionKey);
390340
foreach (var pair in _compactionPipelines.Where(pair =>
391341
string.Equals(pair.Key.SessionKey, sessionKey, StringComparison.Ordinal)).ToArray())
392342
{
@@ -713,7 +663,10 @@ when string.Equals(
713663
}
714664
}
715665
};
716-
var chatClientBuilder = new ChatClientBuilder(ctx.ChatClient);
666+
var baseChatClient = ctx.ChatClient
667+
?? _chatClientOverride
668+
?? _chatClientRegistry.GetChatClient(runtime);
669+
var chatClientBuilder = new ChatClientBuilder(baseChatClient);
717670
var isNativeSubAgent = ctx.CurrentThreadSource?.SubAgent is { } subAgentSource
718671
&& string.Equals(
719672
subAgentSource.RuntimeType,

src/DotCraft.Core/Agents/Runtime/AgentRuntimeContext.cs

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public AgentRuntimeContext()
3131

3232
/// <summary>
3333
/// Copies every member of <paramref name="source"/> so a clone site only states its deltas and cannot
34-
/// silently drop one. Lazily defaulted members are materialized from the source as it is read.
34+
/// silently drop one. Unresolved model runtime values remain unresolved in the clone.
3535
/// </summary>
3636
[SetsRequiredMembers]
3737
public AgentRuntimeContext(AgentRuntimeContext source)
@@ -40,9 +40,9 @@ public AgentRuntimeContext(AgentRuntimeContext source)
4040
Config = source.Config;
4141
ChatClient = source.ChatClient;
4242
ChatClientRegistry = source.ChatClientRegistry;
43-
EffectiveMainModel = source.EffectiveMainModel;
44-
EffectiveProviderId = source.EffectiveProviderId;
45-
EffectiveProviderProtocol = source.EffectiveProviderProtocol;
43+
_effectiveMainModel = source._effectiveMainModel;
44+
_effectiveProviderId = source._effectiveProviderId;
45+
_effectiveProviderProtocol = source._effectiveProviderProtocol;
4646
EffectiveReasoning = source.EffectiveReasoning;
4747
EffectiveSpeed = source.EffectiveSpeed;
4848
WorkspacePath = source.WorkspacePath;
@@ -95,10 +95,10 @@ public AgentRuntimeContext(AgentRuntimeContext source)
9595
public required AppConfig Config { get; init; }
9696

9797
/// <summary>
98-
/// The chat client for AI interactions.
99-
/// Required for subagent spawning and other AI-powered tools.
98+
/// The resolved chat client for AI interactions.
99+
/// Root workspace contexts may leave this unset until an agent is built.
100100
/// </summary>
101-
public required IChatClient ChatClient { get; init; }
101+
public IChatClient? ChatClient { get; init; }
102102

103103
/// <summary>
104104
/// Central provider-neutral registry used to resolve model-specific chat clients.
@@ -112,30 +112,40 @@ public ChatClientRegistry ChatClientRegistry
112112
/// <summary>
113113
/// Effective MainAgent model represented by <see cref="ChatClient"/>.
114114
/// </summary>
115+
private string? _effectiveMainModel;
116+
115117
public string EffectiveMainModel
116118
{
117-
get => string.IsNullOrWhiteSpace(field) ? ChatClientRegistry.ResolveMainModel(Config) : field;
118-
init;
119+
get => string.IsNullOrWhiteSpace(_effectiveMainModel)
120+
? ChatClientRegistry.ResolveMainModel(Config)
121+
: _effectiveMainModel;
122+
init => _effectiveMainModel = value;
119123
}
120124

121125
/// <summary>
122126
/// Effective provider id represented by <see cref="ChatClient"/>.
123127
/// </summary>
128+
private string? _effectiveProviderId;
129+
124130
public string EffectiveProviderId
125131
{
126-
get => string.IsNullOrWhiteSpace(field) ? ChatClientRegistry.ResolveMainProviderId(Config) : field;
127-
init;
132+
get => string.IsNullOrWhiteSpace(_effectiveProviderId)
133+
? ChatClientRegistry.ResolveMainProviderId(Config)
134+
: _effectiveProviderId;
135+
init => _effectiveProviderId = value;
128136
}
129137

130138
/// <summary>
131139
/// Effective provider protocol represented by <see cref="ChatClient"/>.
132140
/// </summary>
141+
private string? _effectiveProviderProtocol;
142+
133143
public string EffectiveProviderProtocol
134144
{
135-
get => string.IsNullOrWhiteSpace(field)
145+
get => string.IsNullOrWhiteSpace(_effectiveProviderProtocol)
136146
? ChatClientRegistry.ResolveMainRuntime(Config, EffectiveProviderId, EffectiveMainModel).Protocol
137-
: field;
138-
init;
147+
: _effectiveProviderProtocol;
148+
init => _effectiveProviderProtocol = value;
139149
}
140150

141151
/// <summary>

src/DotCraft.Core/Dreams/DreamsSessionRunner.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public sealed class DreamsSessionRunner(
3737
DreamStore dreamStore,
3838
ILogger<DreamsSessionRunner>? logger = null) : IDreamsRunner
3939
{
40-
public string ModelId { get; } = chatClientRegistry.ResolveConsolidationModel(config);
40+
public string ModelId => chatClientRegistry.ResolveConsolidationModel(config);
4141

4242
public async Task<DreamsGenerationResult> GenerateAsync(
4343
DreamsRunInput input,
@@ -55,9 +55,9 @@ public async Task<DreamsGenerationResult> GenerateAsync(
5555
var turnIds = new List<string>();
5656
var usage = new TokenUsageInfo();
5757
DreamStoreDescriptor? outputStore = null;
58-
var effectiveModelId = string.IsNullOrWhiteSpace(modelId) ? ModelId : modelId.Trim();
5958
try
6059
{
60+
var effectiveModelId = string.IsNullOrWhiteSpace(modelId) ? ModelId : modelId.Trim();
6161
outputStore = string.IsNullOrWhiteSpace(outputStoreId)
6262
? dreamStore.CreateOutputStore(runId, DateTimeOffset.UtcNow)
6363
: dreamStore.GetStoreDescriptor(outputStoreId);

src/DotCraft.Core/Sessions/Runtime/SessionService.cs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public Task CompleteAsync()
9999
/// </summary>
100100
public sealed partial class SessionService(
101101
AgentFactory agentFactory,
102-
ChatClientAgent defaultAgent,
102+
ChatClientAgent? defaultAgent,
103103
SessionPersistenceService persistence,
104104
SessionGate sessionGate,
105105
HookRunner? hookRunner = null,
@@ -155,6 +155,8 @@ private sealed class ThreadLoadGate
155155
private MaintenanceCoordinator? _maintenanceCoordinator;
156156
private static readonly AsyncLocal<bool> SuppressGoalBroadcastContext = new();
157157
private readonly IAppConfigMonitor? _appConfigMonitor = appConfigMonitor;
158+
private readonly bool _hasExplicitDefaultAgent = defaultAgent != null;
159+
private ChatClientAgent? _defaultAgent = defaultAgent;
158160
private string DataPath => persistence.DataPath;
159161
private readonly ConcurrentDictionary<string, byte> _sessionStartHookThreads = new(StringComparer.Ordinal);
160162

@@ -325,7 +327,8 @@ internal static List<ChatMessage> BuildNativeSubAgentForkHistory(
325327

326328
private McpAppTransientContextStore? McpAppTransientContexts => mcpAppTransientContextStore;
327329

328-
private ChatClientAgent DefaultAgent => defaultAgent;
330+
private ChatClientAgent DefaultAgent =>
331+
_defaultAgent ??= agentFactory.CreateAgentForMode(AgentMode.Agent);
329332

330333
private AgentFactory AgentFactory => agentFactory;
331334

@@ -337,7 +340,7 @@ internal static List<ChatMessage> BuildNativeSubAgentForkHistory(
337340
private ChatClientAgent GetThreadAgentOrDefault(string threadId) =>
338341
_runtimeRegistry.TryGetRuntime(threadId, out var runtime) && runtime.Agent != null
339342
? runtime.Agent
340-
: defaultAgent;
343+
: DefaultAgent;
341344

342345
private bool HasThreadAgent(string threadId) =>
343346
_runtimeRegistry.TryGetRuntime(threadId, out var runtime) && runtime.Agent != null;
@@ -1735,12 +1738,14 @@ private async Task<TurnExecutionResources> CaptureTurnExecutionResourcesAsync(
17351738
ThreadRuntime runtime,
17361739
CancellationToken ct)
17371740
{
1741+
if (!_hasExplicitDefaultAgent || _forcePerThreadAgents)
1742+
await EnsurePerThreadAgentIfMissingAsync(runtime.Thread.Id, runtime.Thread, ct).ConfigureAwait(false);
17381743
using (await AcquireThreadAgentLockAsync(runtime.Thread.Id, ct).ConfigureAwait(false))
17391744
{
17401745
if (!_runtimeRegistry.IsCurrent(runtime.Thread.Id, runtime))
17411746
throw new InvalidOperationException($"Thread '{runtime.Thread.Id}' runtime was replaced during Turn admission.");
17421747
return new TurnExecutionResources(
1743-
runtime.Agent ?? defaultAgent,
1748+
runtime.Agent ?? DefaultAgent,
17441749
runtime.LatestToolSnapshot);
17451750
}
17461751
}
@@ -1906,7 +1911,6 @@ async Task RunRegularTurnAsync()
19061911

19071912
IDisposable? gateLock = null;
19081913
IDisposable? approvalOverride = null;
1909-
var agent = defaultAgent;
19101914
List<ChatMessage>? session = null;
19111915
TokenTracker? tokenTracker = null;
19121916
var itemProjector = new TurnItemProjector(
@@ -2683,7 +2687,7 @@ await FailAndPersistTurnAsync(
26832687
// Resource capture was ordered at admission. It may wait for an in-flight
26842688
// publication, but later configuration changes cannot overtake it.
26852689
var executionResources = await turnContext.Resources.WaitAsync(executionCt).ConfigureAwait(false);
2686-
agent = executionResources.Agent;
2690+
var agent = executionResources.Agent;
26872691
turnRuntime.ToolSnapshot = executionResources.ToolSnapshot;
26882692

26892693
// Bind tracing and token tracking before model history reconstruction.
@@ -6342,11 +6346,12 @@ private static bool TryResolveProviderFunctionCall(
63426346

63436347
private static IChatClient ResolveThreadChatClient(AgentRuntimeContext baseContext, EffectiveModelRuntime runtime)
63446348
{
6345-
if (string.Equals(runtime.ProviderId, baseContext.EffectiveProviderId, StringComparison.OrdinalIgnoreCase)
6349+
if (baseContext.ChatClient is { } chatClient
6350+
&& string.Equals(runtime.ProviderId, baseContext.EffectiveProviderId, StringComparison.OrdinalIgnoreCase)
63466351
&& string.Equals(runtime.Protocol, baseContext.EffectiveProviderProtocol, StringComparison.OrdinalIgnoreCase)
63476352
&& string.Equals(runtime.Model, baseContext.EffectiveMainModel, StringComparison.Ordinal))
63486353
{
6349-
return baseContext.ChatClient;
6354+
return chatClient;
63506355
}
63516356

63526357
return baseContext.ChatClientRegistry.GetChatClient(runtime);

src/DotCraft.Core/Sessions/Runtime/SessionServiceFactory.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ namespace DotCraft.Sessions;
1414

1515
/// <summary>
1616
/// Factory helper that constructs a <see cref="SessionService"/> from an already-built
17-
/// <see cref="DotCraft.Agents.AgentFactory"/> and <see cref="ChatClientAgent"/> plus shared DI services.
18-
/// Avoids boilerplate across channel hosts that each build their own AgentFactory.
17+
/// <see cref="DotCraft.Agents.AgentFactory"/>, an optional default <see cref="ChatClientAgent"/>,
18+
/// and shared DI services. When omitted, the default agent is built on first use.
1919
/// </summary>
2020
public static class SessionServiceFactory
2121
{
@@ -26,7 +26,7 @@ public static class SessionServiceFactory
2626
/// </summary>
2727
public static SessionService Create(
2828
AgentFactory agentFactory,
29-
ChatClientAgent agent,
29+
ChatClientAgent? agent,
3030
IServiceProvider sp,
3131
TimeSpan? approvalTimeout = null)
3232
{

0 commit comments

Comments
 (0)