Wire skills into agent runs: org-scoped, published-only, org-preferred resolution
ISkillCatalog.GetByKeysAsync now takes the org id and resolves each key within that org's namespace only — the org's own published skill, else a shared builtin (null org), never another org's. Org-owned is preferred over the builtin; only Published (golden-tested) skills are injected; the resolved skill@version is recorded in the prompt heading and run trace. AgentRunExecutor threads context.OrganizationId. SeatsPage now loads the org library (builtins + authored + installed), dedupes to one entry per key, and flags drafts (won't run until published). Verified: ArchitectureTests 8/8, IntegrationTests 48/48 (new SkillRunScopingTests: a run assembles the org's own skill over the builtin of the same key, and another org's same-key skill never leaks in), client build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ internal sealed class AgentRunExecutor(
|
||||
var context = await contextProvider.GetAsync(run.SeatId, run.WorkItemId, cancellationToken)
|
||||
?? throw new InvalidOperationException("Agent or task not found for the run.");
|
||||
|
||||
var skills = await skillCatalog.GetByKeysAsync(context.SkillKeys, cancellationToken);
|
||||
var skills = await skillCatalog.GetByKeysAsync(context.OrganizationId, context.SkillKeys, cancellationToken);
|
||||
|
||||
// Working memory: recall the team's most relevant decisions/corrections for this task.
|
||||
var memories = await teamMemory.SearchAsync(
|
||||
|
||||
@@ -35,7 +35,7 @@ internal static class PromptAssembler
|
||||
builder.AppendLine("# Skills");
|
||||
foreach (var skill in ordered)
|
||||
{
|
||||
builder.AppendLine("## " + skill.Name).AppendLine(skill.Body).AppendLine();
|
||||
builder.AppendLine("## " + skill.Name + " (v" + skill.Version + ")").AppendLine(skill.Body).AppendLine();
|
||||
}
|
||||
|
||||
if (context.Docs.Count > 0)
|
||||
@@ -69,7 +69,7 @@ internal static class PromptAssembler
|
||||
{
|
||||
agent = context.AgentName,
|
||||
autonomy = context.Autonomy.ToString(),
|
||||
skills = ordered.Select(s => s.Key).ToArray(),
|
||||
skills = ordered.Select(s => s.Key + "@" + s.Version).ToArray(),
|
||||
docs = context.Docs,
|
||||
memories = memories.Count,
|
||||
apiConfigId = context.ApiConfigId,
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace TeamUp.Modules.Skills.Catalog;
|
||||
internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
|
||||
{
|
||||
public async Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
|
||||
Guid organizationId,
|
||||
IReadOnlyCollection<string> keys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -18,17 +19,28 @@ internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
|
||||
}
|
||||
|
||||
var wanted = keys.ToHashSet();
|
||||
var skills = await db.Skills.Where(s => wanted.Contains(s.SkillKey)).ToListAsync(cancellationToken);
|
||||
|
||||
// Org-scoped: only this org's own skills or shared builtins (null org) — never another org's.
|
||||
// Published-only, so a run never executes an ungated (Draft) skill.
|
||||
var skills = await db.Skills
|
||||
.Where(s => wanted.Contains(s.SkillKey)
|
||||
&& s.Status == SkillStatus.Published
|
||||
&& (s.OrganizationId == organizationId || s.OrganizationId == null))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return skills
|
||||
.GroupBy(s => s.SkillKey)
|
||||
.Select(group => group.OrderByDescending(s => s.Version, StringComparer.Ordinal).First())
|
||||
.Select(group => group
|
||||
.OrderByDescending(s => s.OrganizationId == organizationId) // a forked/authored org skill beats the builtin
|
||||
.ThenByDescending(s => s.Version, StringComparer.Ordinal) // then the latest version in that scope
|
||||
.First())
|
||||
.Select(s =>
|
||||
{
|
||||
var primary = s.Actions.Count > 0 ? s.Actions[0] : null;
|
||||
return new SkillPrompt(
|
||||
s.SkillKey,
|
||||
s.Name,
|
||||
s.Version,
|
||||
s.Body,
|
||||
primary?.Name ?? "respond",
|
||||
(primary?.Risk ?? ActionRisk.Draft).ToString(),
|
||||
|
||||
@@ -4,15 +4,21 @@ namespace TeamUp.SharedKernel.Ai;
|
||||
public sealed record SkillPrompt(
|
||||
string Key,
|
||||
string Name,
|
||||
string Version,
|
||||
string Body,
|
||||
string PrimaryAction,
|
||||
string PrimaryActionRisk,
|
||||
IReadOnlyList<string> Roles);
|
||||
|
||||
/// <summary>Resolves skill prompts by key (latest version). Implemented by the Skills module.</summary>
|
||||
/// <summary>
|
||||
/// Resolves skill prompts for one org's agent run. Each key resolves within that org's namespace
|
||||
/// only — the org's own (published) skill, else a shared builtin — never another org's; latest
|
||||
/// version, org-owned preferred over builtin. Implemented by the Skills module.
|
||||
/// </summary>
|
||||
public interface ISkillCatalog
|
||||
{
|
||||
Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
|
||||
Guid organizationId,
|
||||
IReadOnlyCollection<string> keys,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user