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:
@@ -105,7 +105,12 @@ export function SeatsPage() {
|
|||||||
if (!organizationId) return
|
if (!organizationId) return
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
setTeams(await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`))
|
setTeams(await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`))
|
||||||
setSkills(await api.get<Skill[]>('/api/skills/'))
|
// The org's library = shared builtins + its own authored/installed skills. The API returns
|
||||||
|
// every version (newest first per key); collapse to one selectable entry per key.
|
||||||
|
const lib = await api.get<Skill[]>(`/api/skills/?organizationId=${organizationId}`)
|
||||||
|
const byKey = new Map<string, Skill>()
|
||||||
|
for (const s of lib) if (!byKey.has(s.skillKey)) byKey.set(s.skillKey, s)
|
||||||
|
setSkills([...byKey.values()])
|
||||||
await loadConfigs()
|
await loadConfigs()
|
||||||
})
|
})
|
||||||
}, [organizationId, loadConfigs, run])
|
}, [organizationId, loadConfigs, run])
|
||||||
@@ -359,9 +364,9 @@ export function SeatsPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{skills.map((skill) => (
|
{skills.map((skill) => (
|
||||||
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)}>
|
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)} title={skill.status !== 'Published' ? 'Draft — add roles + a golden test to publish before an agent can run it' : undefined}>
|
||||||
<Badge variant={agent.skillKeys.includes(skill.skillKey) ? 'default' : 'outline'}>
|
<Badge variant={agent.skillKeys.includes(skill.skillKey) ? 'default' : 'outline'}>
|
||||||
{skill.name}
|
{skill.name}{skill.status !== 'Published' ? ' · draft' : ''}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ internal sealed class AgentRunExecutor(
|
|||||||
var context = await contextProvider.GetAsync(run.SeatId, run.WorkItemId, cancellationToken)
|
var context = await contextProvider.GetAsync(run.SeatId, run.WorkItemId, cancellationToken)
|
||||||
?? throw new InvalidOperationException("Agent or task not found for the run.");
|
?? 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.
|
// Working memory: recall the team's most relevant decisions/corrections for this task.
|
||||||
var memories = await teamMemory.SearchAsync(
|
var memories = await teamMemory.SearchAsync(
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ internal static class PromptAssembler
|
|||||||
builder.AppendLine("# Skills");
|
builder.AppendLine("# Skills");
|
||||||
foreach (var skill in ordered)
|
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)
|
if (context.Docs.Count > 0)
|
||||||
@@ -69,7 +69,7 @@ internal static class PromptAssembler
|
|||||||
{
|
{
|
||||||
agent = context.AgentName,
|
agent = context.AgentName,
|
||||||
autonomy = context.Autonomy.ToString(),
|
autonomy = context.Autonomy.ToString(),
|
||||||
skills = ordered.Select(s => s.Key).ToArray(),
|
skills = ordered.Select(s => s.Key + "@" + s.Version).ToArray(),
|
||||||
docs = context.Docs,
|
docs = context.Docs,
|
||||||
memories = memories.Count,
|
memories = memories.Count,
|
||||||
apiConfigId = context.ApiConfigId,
|
apiConfigId = context.ApiConfigId,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace TeamUp.Modules.Skills.Catalog;
|
|||||||
internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
|
internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
|
public async Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
|
||||||
|
Guid organizationId,
|
||||||
IReadOnlyCollection<string> keys,
|
IReadOnlyCollection<string> keys,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -18,17 +19,28 @@ internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
|
|||||||
}
|
}
|
||||||
|
|
||||||
var wanted = keys.ToHashSet();
|
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
|
return skills
|
||||||
.GroupBy(s => s.SkillKey)
|
.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 =>
|
.Select(s =>
|
||||||
{
|
{
|
||||||
var primary = s.Actions.Count > 0 ? s.Actions[0] : null;
|
var primary = s.Actions.Count > 0 ? s.Actions[0] : null;
|
||||||
return new SkillPrompt(
|
return new SkillPrompt(
|
||||||
s.SkillKey,
|
s.SkillKey,
|
||||||
s.Name,
|
s.Name,
|
||||||
|
s.Version,
|
||||||
s.Body,
|
s.Body,
|
||||||
primary?.Name ?? "respond",
|
primary?.Name ?? "respond",
|
||||||
(primary?.Risk ?? ActionRisk.Draft).ToString(),
|
(primary?.Risk ?? ActionRisk.Draft).ToString(),
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ namespace TeamUp.SharedKernel.Ai;
|
|||||||
public sealed record SkillPrompt(
|
public sealed record SkillPrompt(
|
||||||
string Key,
|
string Key,
|
||||||
string Name,
|
string Name,
|
||||||
|
string Version,
|
||||||
string Body,
|
string Body,
|
||||||
string PrimaryAction,
|
string PrimaryAction,
|
||||||
string PrimaryActionRisk,
|
string PrimaryActionRisk,
|
||||||
IReadOnlyList<string> Roles);
|
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
|
public interface ISkillCatalog
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
|
Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
|
||||||
|
Guid organizationId,
|
||||||
IReadOnlyCollection<string> keys,
|
IReadOnlyCollection<string> keys,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using TeamUp.Modules.Assembler.Queue;
|
||||||
|
using TeamUp.Modules.Assembler.Runtime;
|
||||||
|
using TeamUp.Modules.Skills.Domain;
|
||||||
|
using TeamUp.Modules.Skills.Indexing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeamUp.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the library → delivery loop: when an AI seat runs a task, the skill bodies assembled into
|
||||||
|
/// its prompt are resolved within the org's namespace only — the org's own (authored/forked) skill is
|
||||||
|
/// preferred over the shared builtin of the same key, and another org's same-key skill never leaks in.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SkillRunScopingTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||||
|
{
|
||||||
|
private const string OrgABody = "ACME_ORG_A_CUSTOM_IMPL_BODY — our house implementation steps.";
|
||||||
|
private const string OrgBPoison = "ORG_B_POISON_BODY_MUST_NEVER_APPEAR_IN_ANOTHER_ORGS_PROMPT.";
|
||||||
|
|
||||||
|
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||||
|
|
||||||
|
private sealed record IdResponse(Guid Id);
|
||||||
|
|
||||||
|
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
||||||
|
|
||||||
|
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
|
||||||
|
|
||||||
|
private sealed record TaskResponse(Guid Id);
|
||||||
|
|
||||||
|
private sealed record SyncResult(int Indexed);
|
||||||
|
|
||||||
|
private sealed record RunResponse(
|
||||||
|
Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status,
|
||||||
|
string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_run_assembles_the_orgs_own_skill_over_builtin_and_never_another_orgs()
|
||||||
|
{
|
||||||
|
var settings = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["GitSource:Provider"] = "filesystem",
|
||||||
|
["GitSource:Root"] = LocateSkillsDirectory(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
|
||||||
|
using var anon = factory.CreateClient();
|
||||||
|
|
||||||
|
var owner = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
|
||||||
|
{
|
||||||
|
organizationName = "OrgA",
|
||||||
|
ownerEmail = "owner@alia.test",
|
||||||
|
ownerDisplayName = "Owner",
|
||||||
|
ownerPassword = "Passw0rd!",
|
||||||
|
});
|
||||||
|
using var client = Authed(factory, owner.Token);
|
||||||
|
|
||||||
|
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "OrgA" });
|
||||||
|
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
|
||||||
|
var config = await PostOk<IdResponse>(client, "/api/integrations/api-configs", new
|
||||||
|
{
|
||||||
|
organizationId = owner.OrganizationId,
|
||||||
|
name = "Stub",
|
||||||
|
provider = "stub",
|
||||||
|
model = "gemini-pro",
|
||||||
|
apiKey = "sk-demo-key",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shared builtin "code-implementation" exists (Published) after a sync.
|
||||||
|
var sync = await PostOk<SyncResult>(client, "/api/skills/sync", new { });
|
||||||
|
Assert.True(sync.Indexed >= 8);
|
||||||
|
|
||||||
|
// Org A authors its OWN "code-implementation" (same key as the builtin) with a distinctive body.
|
||||||
|
await PostOk<object>(client, "/api/skills/authored", new
|
||||||
|
{
|
||||||
|
organizationId = owner.OrganizationId,
|
||||||
|
skillKey = "code-implementation",
|
||||||
|
name = "ACME Code Impl",
|
||||||
|
version = "1.0.0",
|
||||||
|
summary = "Our house implementation skill.",
|
||||||
|
roles = new[] { "engineer" },
|
||||||
|
inputs = (string?)null,
|
||||||
|
outputs = (string?)null,
|
||||||
|
actions = new[] { new { name = "implement-code", risk = "draft", description = (string?)null } },
|
||||||
|
tools = Array.Empty<string>(),
|
||||||
|
context = Array.Empty<string>(),
|
||||||
|
visibility = "private",
|
||||||
|
minTier = "free",
|
||||||
|
body = OrgABody,
|
||||||
|
goldenTests = new[] { new { input = "task", expected = "code" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// A DIFFERENT org publishes its own "code-implementation" — it must never reach org A's prompt.
|
||||||
|
await SeedSkillAsync(factory, Guid.NewGuid(), "code-implementation", OrgBPoison);
|
||||||
|
|
||||||
|
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Backend Engineer" });
|
||||||
|
await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new
|
||||||
|
{
|
||||||
|
name = "Edison",
|
||||||
|
monogram = "ED",
|
||||||
|
autonomy = "Gated",
|
||||||
|
apiConfigId = config.Id,
|
||||||
|
skillKeys = new[] { "code-implementation" },
|
||||||
|
docs = Array.Empty<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var task = await PostOk<TaskResponse>(client, "/api/orgboard/tasks", new
|
||||||
|
{
|
||||||
|
teamId = team.Id,
|
||||||
|
title = "Implement the logout endpoint",
|
||||||
|
description = "POST /logout clears the session.",
|
||||||
|
type = "Story",
|
||||||
|
});
|
||||||
|
|
||||||
|
var run = await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
|
||||||
|
await DrainOneJob(factory);
|
||||||
|
|
||||||
|
var done = await client.GetFromJsonAsync<RunResponse>($"/api/assembler/runs/{run.Id}");
|
||||||
|
Assert.Equal("Completed", done!.Status);
|
||||||
|
Assert.Equal("implement-code", done.ActionType);
|
||||||
|
|
||||||
|
// The org's own skill body was assembled in — preferred over the builtin of the same key…
|
||||||
|
Assert.Contains(OrgABody, done.Prompt);
|
||||||
|
// …and another org's same-key skill never leaked across the tenant boundary.
|
||||||
|
Assert.DoesNotContain(OrgBPoison, done.Prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedSkillAsync(TeamUpWebFactory factory, Guid orgId, string key, string body)
|
||||||
|
{
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var indexer = scope.ServiceProvider.GetRequiredService<SkillIndexer>();
|
||||||
|
var manifest = new SkillManifest
|
||||||
|
{
|
||||||
|
Id = key,
|
||||||
|
Name = key,
|
||||||
|
Version = "1.0.0",
|
||||||
|
Summary = "another org's skill",
|
||||||
|
Roles = ["engineer"],
|
||||||
|
Visibility = "public",
|
||||||
|
GoldenTests = [new GoldenExample { Input = "task", Expected = "code" }],
|
||||||
|
};
|
||||||
|
await indexer.IndexAsync(manifest, body, SkillOwnership.Authored(orgId, Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DrainOneJob(TeamUpWebFactory factory)
|
||||||
|
{
|
||||||
|
await using var scope = factory.Services.CreateAsyncScope();
|
||||||
|
var queue = scope.ServiceProvider.GetRequiredService<JobQueue>();
|
||||||
|
var job = await queue.ClaimNextAsync("test-worker");
|
||||||
|
Assert.NotNull(job);
|
||||||
|
await scope.ServiceProvider.GetRequiredService<AgentRunExecutor>().ProcessAsync(job!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
||||||
|
{
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
|
||||||
|
{
|
||||||
|
var response = await client.PostAsJsonAsync(url, body);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var value = await response.Content.ReadFromJsonAsync<T>();
|
||||||
|
Assert.NotNull(value);
|
||||||
|
return value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateSkillsDirectory()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx")))
|
||||||
|
{
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotNull(dir);
|
||||||
|
return Path.Combine(dir!.FullName, "skills");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user