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
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ public async Task<AgentTask> ResolveAsync(AgentTask task, Guid teamId, Cancellat
/// silently falling back — the operator's explicit choice is honoured or surfaced, never substituted. A disabled
/// model is "not part of the usable pool", so a pinned-then-disabled model fails rather than runs. No reference →
/// the task is returned untouched (byte-identical).
///
/// <para>The one exception is a task bound to an operator's allowed model pool (<see cref="AgentTask.AllowedModelIds"/>):
/// the operator chose the POOL as the fallback, and the dispatch owns that choice (<c>HarnessModelReconciler</c>
/// walks pinned row → model on the pinned credential → model on any pooled credential → the credential's best
/// pooled row → the pool default, names the move on the run's timeline, or fails with
/// <c>agent_model_pool_unresolvable</c>). So an unusable pinned row is left on the task for it — failing here would
/// end the run before any pooled row was tried, with no note and no typed cause.</para>
/// </summary>
private async Task<AgentTask> ApplyCredentialedModelAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken)
{
Expand All @@ -95,8 +102,12 @@ private async Task<AgentTask> ApplyCredentialedModelAsync(AgentTask task, Guid t
var row = await _db.ModelCredentialModel.AsNoTracking()
.Where(m => m.Id == rowId && m.Enabled && m.Credential.TeamId == teamId && m.Credential.DeletedDate == null && m.Credential.Status == CredentialStatus.Active)
.Select(m => new { m.ModelId, m.ModelCredentialId })
.SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false)
?? throw new AgentDefinitionResolutionException($"Selected model {rowId} is not an active, enabled credentialed model for this team.");
.SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false);

if (row is null && task.AllowedModelIds is { Count: > 0 }) return task;

if (row is null)
throw new AgentDefinitionResolutionException($"Selected model {rowId} is not an active, enabled credentialed model for this team.");

return task with { Model = row.ModelId, ModelCredentialId = row.ModelCredentialId };
}
Expand Down
111 changes: 87 additions & 24 deletions backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using CodeSpace.Messages.Failures;

namespace CodeSpace.Core.Services.Agents.Exceptions;

/// <summary>
/// An agent run bound to the operator's allowed model pool found nothing in it that can run — every pooled model is
/// disabled, or its credential is no longer active — so the run is refused rather than put on a model outside the pool.
/// Its code is the run's exit reason, and a respawn meets the same pool, so the node treats it as deterministic.
/// </summary>
public sealed class AgentModelPoolUnresolvableException : Exception, IFailure
{
public AgentModelPoolUnresolvableException(string message) : base(message) { }

public FailureKind Kind => FailureKind.Unprocessable;
public string Code => FailureCodes.AgentModelPoolUnresolvable;
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,22 @@ namespace CodeSpace.Core.Services.Agents;
/// row, so a pin repair makes the whole (harness, model, credential) triple runnable. Blanking the model here would
/// drop a VALID operator choice in the common consistent case, so this layer never does; it only swaps the harness for
/// one that can drive the model's provider.</para>
///
/// <para>The ONE exception is the run's allowed model pool (<see cref="AgentTask.AllowedModelIds"/>, the model analogue of
/// the harness allow-list): a bounded task runs on a POOLED ROW — the one <see cref="AllowedModelPool"/> chooses for its
/// pinned row, model and credential (a planner-authored model outside the pool lands on the pool's default row) —
/// reported as <see cref="HarnessReconciliation.PooledModel"/> for the executor to run and persist, with a note whenever
/// the model or its credential moved, and the harness is reconciled against THAT row's provider. An unbounded task is
/// reconciled exactly as before.</para>
/// </summary>
public interface IHarnessModelReconciler
{
/// <summary>Resolve the harness KIND to ACTUALLY run for <paramref name="task"/>: the authored one when it can drive the model's provider (from the pinned credential or, failing a pin, the named model's pool row), else a registered, run-ADMITTED harness that can (the always-runnable fallback). The caller resolves the kind to an adapter, so the registry stays the single owner of kind→adapter.</summary>
Task<HarnessReconciliation> ReconcileAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken);
}

/// <summary>The harness KIND to run, whether it was REPAIRED away from the authored one, and a human-facing note for the timeline when it was.</summary>
public sealed record HarnessReconciliation(string HarnessKind, bool Repaired, string? Note);
/// <summary>The harness KIND to run, whether it was REPAIRED away from the authored one, and a human-facing note for the timeline when it was — plus, for a task bounded to an allowed model pool, the pooled row it runs on (null when unbounded, or when the pool resolves nothing any more) and a note when the authored model had to move or cannot be honoured.</summary>
public sealed record HarnessReconciliation(string HarnessKind, bool Repaired, string? Note, PooledModelRow? PooledModel = null, string? PoolNote = null);

public sealed class HarnessModelReconciler : IHarnessModelReconciler, IScopedDependency
{
Expand All @@ -61,11 +68,16 @@ public HarnessModelReconciler(IAgentHarnessRegistry harnesses, IModelPoolSelecto

public async Task<HarnessReconciliation> ReconcileAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken)
{
var provider = await ResolveModelProviderAsync(task, teamId, cancellationToken).ConfigureAwait(false);
var choice = await ChoosePooledModelAsync(task, teamId, cancellationToken).ConfigureAwait(false);
var pooled = choice?.Row;
var poolNote = choice?.Note;

// A bounded task runs on its pooled row, so the harness follows THAT row's provider, not the authored model's.
var provider = pooled?.Provider ?? await ResolveModelProviderAsync(task, teamId, cancellationToken).ConfigureAwait(false);

// No provider to reconcile against (no pin AND no pooled model name) → return the authored kind verbatim (the
// caller's registry resolves it; a genuinely-unregistered kind surfaces there, unchanged).
if (provider is null) return new HarnessReconciliation(task.Harness, false, null);
if (provider is null) return new HarnessReconciliation(task.Harness, false, null, pooled, poolNote);

// The repair chooses from the registry CLAMPED to the run's harness allow-list (null/empty = the whole registry,
// which is every non-supervisor path and every pre-field task envelope). Without this clamp the run-time repair
Expand All @@ -75,7 +87,18 @@ public async Task<HarnessReconciliation> ReconcileAsync(AgentTask task, Guid tea
// admitted one, so the floor stays inside the list too.
var pool = AgentHarnessPool.Clamp(_harnesses.All, task.AllowedHarnessKinds);

return Reconcile(task.Harness, provider, pool, AgentHarnessDefaults.DefaultHarness);
return Reconcile(task.Harness, provider, pool, AgentHarnessDefaults.DefaultHarness) with { PooledModel = pooled, PoolNote = poolNote };
}

/// <summary>The allowed-pool choice for a bounded task — its pinned row, model and credential, as <see cref="AllowedModelPool"/> reads them over the pool's described rows. Null for an unbounded task (every pre-field envelope).</summary>
private async Task<PooledModelChoice?> ChoosePooledModelAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken)
{
if (task.AllowedModelIds is not { Count: > 0 } pool) return null;

var pin = new ModelPin(task.ModelCredentialModelId, task.Model, task.ModelCredentialId);
var rows = await _modelSelector.ListPoolRowsAsync(teamId, AllowedModelPool.RowsToRead(pool, pin), cancellationToken).ConfigureAwait(false);

return AllowedModelPool.Choose(pool, rows, pin);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using CodeSpace.Messages.Agents;

namespace CodeSpace.Core.Services.Agents.ModelCredentials;

/// <summary>
/// Which pooled row an authored model pin runs on inside an operator's allowed model pool — the ONE choice the launch
/// (a single agent's pinned model, clamped before its config is frozen) and the dispatch (every bounded agent run, via
/// <c>HarnessModelReconciler</c>) both make, so a launch disposition and the run it predicts cannot disagree. Pure: it
/// decides over the rows <see cref="IModelPoolSelector.ListPoolRowsAsync"/> described; no I/O.
///
/// <para>A model NAME is unique only per credential, so the choice walks from the most specific thing authored to the
/// least: the pinned ROW, when it is pooled and usable; the pooled row carrying the pinned model on the pinned
/// credential; a pooled row carrying the model name; the pinned credential's best pooled row (a persona may pin only a
/// credential); the pool's own default row. Ranking follows the agent-plane default (<see cref="AgentPlaneModelRanking"/>),
/// row id breaking ties. Whenever the chosen row differs from what was authored — another model, or the same model on
/// another credential — the choice carries a note naming what moved and why: never in the pool, or in the pool but no
/// longer usable.</para>
/// </summary>
public static class AllowedModelPool
{
internal const string NothingRunsNote = "None of this run's allowed models is enabled under an active credential, so nothing inside the pool can run.";

/// <summary>The rows to describe for one choice: the pool, plus a pinned row the pool does not list — so a note can name the model and credential it moved away from.</summary>
public static IReadOnlyList<Guid> RowsToRead(IReadOnlyList<Guid> pool, ModelPin pin) =>
pin.RowId is { } row && !pool.Contains(row) ? [.. pool, row] : pool;

public static PooledModelChoice Choose(IReadOnlyList<Guid> pool, IReadOnlyList<PooledModelRow> rows, ModelPin pin)
{
var usable = rows.Where(r => r.Usable && pool.Contains(r.Id)).ToList();
var authored = Authored(rows, pin);

var row = usable.FirstOrDefault(r => r.Id == pin.RowId)
?? OnCredential(usable, authored.CredentialId, authored.Model)
?? Best(usable.Where(r => Named(r, authored.Model)))
?? OnCredential(usable, authored.CredentialId, model: null)
?? Best(usable);

if (row is null) return new PooledModelChoice(null, NothingRunsNote);

return new PooledModelChoice(row, Moved(authored, row) ? Describe(pool, rows, authored, row) : null);
}

/// <summary>What the pin authored, in model + credential terms: a pinned row the rows describe speaks for itself; otherwise the pin's own name and credential.</summary>
private static AuthoredPin Authored(IReadOnlyList<PooledModelRow> rows, ModelPin pin)
{
var pinned = rows.FirstOrDefault(r => r.Id == pin.RowId);
var credentialId = pinned?.ModelCredentialId ?? pin.CredentialId;

return new AuthoredPin(pin.RowId, pinned?.ModelId ?? NullIfBlank(pin.Model), credentialId, rows.FirstOrDefault(r => r.ModelCredentialId == credentialId)?.CredentialName);
}

/// <summary>The best pooled row on <paramref name="credentialId"/> — carrying <paramref name="model"/> when one is named. Null when no credential was authored.</summary>
private static PooledModelRow? OnCredential(IReadOnlyList<PooledModelRow> usable, Guid? credentialId, string? model) =>
credentialId is { } credential ? Best(usable.Where(r => r.ModelCredentialId == credential && (model is null || Named(r, model)))) : null;

private static PooledModelRow? Best(IEnumerable<PooledModelRow> rows) =>
AgentPlaneModelRanking.Rank(rows, r => r.IsDefault, r => r.ProbedCapabilityTier, r => r.CapabilityTier).ThenBy(r => r.Id).FirstOrDefault();

/// <summary>Model ids match case-insensitively — parity with every other pool lookup.</summary>
private static bool Named(PooledModelRow row, string? model) => model is not null && string.Equals(row.ModelId, model, StringComparison.OrdinalIgnoreCase);

private static bool Moved(AuthoredPin authored, PooledModelRow row) =>
(authored.RowId is { } id && id != row.Id) || (authored.Model is { } model && !Named(row, model)) || (authored.CredentialId is { } credential && credential != row.ModelCredentialId);

/// <summary>The note for a moved pin, worded by cause: the same model on another credential, a model or credential the pool does not hold, or one it holds that can no longer run.</summary>
private static string Describe(IReadOnlyList<Guid> pool, IReadOnlyList<PooledModelRow> rows, AuthoredPin authored, PooledModelRow row)
{
var instead = $"running '{row.ModelId}' on '{row.CredentialName}' instead.";

if (authored.Model is { } model && Named(row, model))
return $"Model '{model}' has no usable row on {(authored.CredentialName is { } name ? $"credential '{name}'" : "the credential it was pinned to")} in this run's allowed model pool; {instead}";

return $"{Subject(authored)} {Cause(pool, rows, authored)}; {instead}";
}

private static string Subject(AuthoredPin authored) => authored switch
{
{ Model: { } model, CredentialName: { } name } => $"Model '{model}' on '{name}'",
{ Model: { } model } => $"Model '{model}'",
{ CredentialId: not null, CredentialName: { } credential } => $"Credential '{credential}'",
{ CredentialId: not null } => "The pinned credential",
_ => "The pinned model",
};

private static string Cause(IReadOnlyList<Guid> pool, IReadOnlyList<PooledModelRow> rows, AuthoredPin authored)
{
if (authored.Model is null && authored.CredentialId is not null) return "carries no usable model in this run's allowed model pool";

return rows.Any(r => pool.Contains(r.Id) && !r.Usable && Matches(r, authored))
? "is in this run's allowed model pool but disabled, or its credential is no longer active"
: "is not in this run's allowed model pool";
}

/// <summary>Whether a row is the authored thing — the pinned row itself, or the authored model on the authored credential (any credential when none was pinned).</summary>
private static bool Matches(PooledModelRow row, AuthoredPin authored) =>
row.Id == authored.RowId || (Named(row, authored.Model) && (authored.CredentialId is null || row.ModelCredentialId == authored.CredentialId));

private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();

private sealed record AuthoredPin(Guid? RowId, string? Model, Guid? CredentialId, string? CredentialName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ public interface IModelPoolSelector
return pool.Count == 0 ? null : await ResolveDispatchAsync(teamId, pool[0].ModelId, allowedRowIds, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// The credentialed-model rows <paramref name="rowIds"/> names, TEAM-SCOPED and whatever their state: each row with
/// whether it can run at all (enabled, under an active, non-deleted credential), the credential it runs on and that
/// credential's name, its provider, and the agent-plane ranking inputs. The allowed-model-pool choice
/// (<c>AllowedModelPool</c>) reads it to run an authored pin on its OWN pooled row — never a same-named row of another
/// credential — and to name what moved, and why, when it cannot. The default implementation (a fake that models no
/// rows) lists nothing, so a bounded choice over it fails closed rather than escape its pool.
/// </summary>
Task<IReadOnlyList<PooledModelRow>> ListPoolRowsAsync(Guid teamId, IReadOnlyList<Guid> rowIds, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<PooledModelRow>>(Array.Empty<PooledModelRow>());

/// <summary>
/// List the team's credentialed pool models (model id + provider) the brain MAY dispatch — bounded to
/// <paramref name="allowedRowIds"/> (null/empty = ALL the team's enabled rows under active credentials). For
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ public ModelPoolSelector(CodeSpaceDbContext db, IPayloadEncryptor encryptor)
return pick is null ? null : new ModelDispatchRef { ModelId = pick.ModelId, ModelCredentialId = pick.ModelCredentialId, Provider = pick.Provider };
}

public async Task<IReadOnlyList<PooledModelRow>> ListPoolRowsAsync(Guid teamId, IReadOnlyList<Guid> rowIds, CancellationToken cancellationToken) =>
await _db.ModelCredentialModel.AsNoTracking()
.Where(m => rowIds.Contains(m.Id) && m.Credential.TeamId == teamId)
.Select(m => new PooledModelRow
{
Id = m.Id, ModelId = m.ModelId, ModelCredentialId = m.ModelCredentialId, CredentialName = m.Credential.DisplayName, Provider = m.Credential.Provider,
Usable = m.Enabled && m.Credential.DeletedDate == null && m.Credential.Status == CredentialStatus.Active,
IsDefault = m.IsDefault, CapabilityTier = m.CapabilityTier, ProbedCapabilityTier = m.ProbedCapabilityTier,
})
.ToListAsync(cancellationToken).ConfigureAwait(false);

public async Task<IReadOnlyList<PoolModelInfo>> ListPoolAsync(Guid teamId, IReadOnlyList<Guid>? allowedRowIds, CancellationToken cancellationToken)
{
var query = _db.ModelCredentialModel.AsNoTracking()
Expand Down
Loading
Loading