Skip to content

Commit 5ac7520

Browse files
authored
Merge pull request #112 from isartor-ai/codex/fix-agent-registry-write-path
fix(agents): write saved profiles to writable overlay
2 parents 9dde23d + 04befd7 commit 5ac7520

9 files changed

Lines changed: 121 additions & 9 deletions

docker/docker-compose.quickstart.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ services:
6565
Storage__RootPath: /app/storage
6666
Agents__Skills__SkillsDirectory: /app/skills
6767
Agents__Registry__AgentsDirectory: /app/agents
68+
Agents__Registry__WritableAgentsDirectory: /app/storage/agents
6869
# Tokenless: deterministic mock model — no API key required.
6970
Anthropic__Provider: "mock"
7071
# No external integrations needed for the quickstart.
@@ -77,6 +78,7 @@ services:
7778
Jwt__Audience: "autofac-dev"
7879
Jwt__DevTokensEnabled: "true"
7980
volumes:
81+
- api_storage:/app/storage
8082
- ../.github/skills:/app/skills:ro
8183
- ../agents:/app/agents:ro
8284
depends_on:
@@ -100,3 +102,6 @@ services:
100102
ports: ["3002:80"]
101103
depends_on:
102104
api: { condition: service_healthy }
105+
106+
volumes:
107+
api_storage:

docker/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ services:
7575
Storage__RootPath: /app/storage
7676
Agents__Skills__SkillsDirectory: /app/skills
7777
Agents__Registry__AgentsDirectory: /app/agents
78+
Agents__Registry__WritableAgentsDirectory: /app/storage/agents
7879
Sandboxes__Docker__Enabled: "false"
7980
Sandboxes__Docker__DockerEndpoint: "unix:///var/run/docker.sock"
8081
Tracing__OtlpEndpoint: "http://jaeger:4317"

docs/agent-and-skill-authoring.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ configured directories (`Agents:Registry:AgentsDirectory` and
55
`Agents:Skills:SkillsDirectory`). The defaults ship under `agents/` and
66
`.github/skills/`.
77

8+
When the bundled agents directory is mounted read-only, set
9+
`Agents:Registry:WritableAgentsDirectory` to a writable overlay directory.
10+
Autofac loads `AgentsDirectory` first and then `WritableAgentsDirectory`, so
11+
admin UI saves can customize or add agents without mutating the shipped files.
12+
If `WritableAgentsDirectory` is omitted, saves use `AgentsDirectory` for
13+
backward compatibility.
14+
815
## Agents (`agents/<id>/AGENT.md`)
916

1017
An agent profile declares what an agent is allowed to do; the Markdown body is

src/Autofac.Agents/AgentOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,11 @@ public sealed class AgentOptions
1010
/// built-in agents of the same id. Defaults to empty (no file agents loaded).
1111
/// </summary>
1212
public string AgentsDirectory { get; set; } = string.Empty;
13+
14+
/// <summary>
15+
/// Absolute or relative path where UI/API-authored agent overlays are written.
16+
/// Defaults to <see cref="AgentsDirectory"/> for backward compatibility. Set
17+
/// this separately when <see cref="AgentsDirectory"/> is mounted read-only.
18+
/// </summary>
19+
public string WritableAgentsDirectory { get; set; } = string.Empty;
1320
}

src/Autofac.Agents/AgentRegistryPaths.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ public sealed record AgentRegistryPaths(
77
string AgentsDirectory,
88
string SkillsDirectory)
99
{
10+
public string WritableAgentsDirectory { get; init; } = AgentsDirectory;
11+
1012
public static AgentRegistryPaths Resolve(IConfiguration configuration)
1113
{
1214
ArgumentNullException.ThrowIfNull(configuration);
@@ -23,6 +25,12 @@ public static AgentRegistryPaths Resolve(IConfiguration configuration)
2325
agentsDirectory = Path.GetFullPath("agents");
2426
}
2527

28+
var writableAgentsDirectory = ResolveDirectory(agentOptions.WritableAgentsDirectory);
29+
if (string.IsNullOrWhiteSpace(writableAgentsDirectory))
30+
{
31+
writableAgentsDirectory = agentsDirectory;
32+
}
33+
2634
var skillsDirectory = ResolveDirectory(skillOptions.SkillsDirectory);
2735
if (string.IsNullOrWhiteSpace(skillsDirectory))
2836
{
@@ -33,7 +41,10 @@ public static AgentRegistryPaths Resolve(IConfiguration configuration)
3341
}
3442
}
3543

36-
return new AgentRegistryPaths(agentsDirectory, skillsDirectory);
44+
return new AgentRegistryPaths(agentsDirectory, skillsDirectory)
45+
{
46+
WritableAgentsDirectory = writableAgentsDirectory
47+
};
3748
}
3849

3950
private static string ResolveDirectory(string? configuredDirectory)

src/Autofac.Agents/DependencyInjection.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public static IServiceCollection AddAutofacAgents(
2020
{
2121
var paths = AgentRegistryPaths.Resolve(configuration);
2222
services.AddSingleton(paths);
23-
services.AddSingleton<IAgentRegistry>(new FileAgentRegistry(paths.AgentsDirectory));
23+
services.AddSingleton<IAgentRegistry>(new FileAgentRegistry(paths));
2424
services.AddSingleton<ISkillRepository>(new SkillRepository(paths.SkillsDirectory));
2525
services.AddSingleton<IAgentRegistryEditor, FileAgentRegistryEditor>();
2626
services.AddSingleton<IAgentPromptAssembler, AgentPromptAssembler>();

src/Autofac.Agents/IAgentRegistry.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public FileAgentRegistry(string agentsDirectory)
2929
{
3030
}
3131

32+
public FileAgentRegistry(AgentRegistryPaths paths)
33+
: this(() => LoadFromDirectories(paths))
34+
{
35+
}
36+
3237
private FileAgentRegistry(Func<IReadOnlyList<AgentProfile>> fileProfileLoader)
3338
{
3439
_fileProfileLoader = fileProfileLoader;
@@ -58,4 +63,22 @@ private Dictionary<string, AgentProfile> BuildCatalog()
5863

5964
return byId;
6065
}
66+
67+
private static IReadOnlyList<AgentProfile> LoadFromDirectories(AgentRegistryPaths paths)
68+
{
69+
ArgumentNullException.ThrowIfNull(paths);
70+
71+
var profiles = new List<AgentProfile>();
72+
var directories = new[] { paths.AgentsDirectory, paths.WritableAgentsDirectory }
73+
.Where(static directory => !string.IsNullOrWhiteSpace(directory))
74+
.Select(Path.GetFullPath)
75+
.Distinct(StringComparer.OrdinalIgnoreCase);
76+
77+
foreach (var directory in directories)
78+
{
79+
profiles.AddRange(MarkdownAgentLoader.LoadFromDirectory(directory));
80+
}
81+
82+
return profiles;
83+
}
6184
}

src/Autofac.Agents/IAgentRegistryEditor.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public ManagedAgentDocument Save(AgentProfile profile)
4545
throw new InvalidOperationException("Agent id is required.");
4646
}
4747

48-
var destinationPath = GetAgentFilePath(profile.AgentId);
48+
var destinationPath = GetWritableAgentFilePath(profile.AgentId);
4949
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
5050

5151
var rawMarkdown = AgentMarkdownSerializer.Serialize(profile);
@@ -74,7 +74,7 @@ public ManagedAgentDocument Upload(string fileName, string content)
7474
throw new InvalidOperationException("Uploaded agent file must declare an id in frontmatter or use a named file.");
7575
}
7676

77-
var destinationPath = GetAgentFilePath(parsed.AgentId);
77+
var destinationPath = GetWritableAgentFilePath(parsed.AgentId);
7878
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
7979
File.WriteAllText(destinationPath, content.TrimEnd() + Environment.NewLine, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
8080

@@ -84,7 +84,7 @@ public ManagedAgentDocument Upload(string fileName, string content)
8484

8585
private ManagedAgentDocument ToDocument(AgentProfile profile)
8686
{
87-
var effectiveFilePath = GetAgentFilePath(profile.AgentId);
87+
var effectiveFilePath = GetWritableAgentFilePath(profile.AgentId);
8888
if (File.Exists(effectiveFilePath))
8989
{
9090
return new ManagedAgentDocument(
@@ -94,14 +94,28 @@ private ManagedAgentDocument ToDocument(AgentProfile profile)
9494
effectiveFilePath);
9595
}
9696

97+
var sourceFilePath = GetConfiguredAgentFilePath(profile.AgentId);
98+
if (!string.Equals(sourceFilePath, effectiveFilePath, StringComparison.OrdinalIgnoreCase) &&
99+
File.Exists(sourceFilePath))
100+
{
101+
return new ManagedAgentDocument(
102+
profile,
103+
File.ReadAllText(sourceFilePath, Encoding.UTF8),
104+
sourceFilePath,
105+
sourceFilePath);
106+
}
107+
97108
return new ManagedAgentDocument(
98109
profile,
99110
AgentMarkdownSerializer.Serialize(profile),
100111
effectiveFilePath,
101112
null);
102113
}
103114

104-
private string GetAgentFilePath(string agentId) =>
115+
private string GetWritableAgentFilePath(string agentId) =>
116+
Path.Combine(_paths.WritableAgentsDirectory, NormalizeAgentId(agentId), "AGENT.md");
117+
118+
private string GetConfiguredAgentFilePath(string agentId) =>
105119
Path.Combine(_paths.AgentsDirectory, NormalizeAgentId(agentId), "AGENT.md");
106120

107121
private static string DeriveDirectoryId(string fileName)

tests/Autofac.Api.Tests/AgentsControllerTests.cs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,43 @@ public void Upsert_WritesOverlayFileAndReturnsSavedAgent()
6565
Assert.True(File.Exists(Path.Combine(fixture.AgentsDirectory, "custom-agent", "AGENT.md")));
6666
}
6767

68+
[Fact]
69+
public void Upsert_WhenWritableAgentDirectoryDiffers_WritesOverlayFileAndReloadsSavedAgent()
70+
{
71+
using var fixture = new AgentRegistryFixture(useWritableOverlay: true);
72+
var controller = fixture.CreateController();
73+
74+
var result = controller.Upsert("business-analyst", new UpsertAgentRequest(
75+
AgentId: "business-analyst",
76+
Name: "Business Analyst",
77+
Description: "Turns issues into requirements.",
78+
Category: "analysis",
79+
Runner: "agent-model",
80+
Skills:
81+
[
82+
new AgentSkillBinding(
83+
"requirements",
84+
"Requirements",
85+
"Shape requirements.",
86+
["requirement-design"],
87+
"test-driven-development")
88+
],
89+
SupportedActions: ["requirement-design"],
90+
SupportedEnvironments: ["all"],
91+
SupportedPolicyTags: ["requirement-design"],
92+
SystemPrompt: "Write crisp requirements."));
93+
94+
var ok = Assert.IsType<OkObjectResult>(result);
95+
var detail = Assert.IsType<AgentDetail>(ok.Value);
96+
97+
var sourcePath = Path.Combine(fixture.AgentsDirectory, "business-analyst", "AGENT.md");
98+
var overlayPath = Path.Combine(fixture.WritableAgentsDirectory, "business-analyst", "AGENT.md");
99+
Assert.False(File.Exists(sourcePath));
100+
Assert.True(File.Exists(overlayPath));
101+
Assert.Equal("test-driven-development", Assert.Single(detail.Skills).SkillManifestId);
102+
Assert.Equal(Path.GetFullPath(overlayPath), detail.EffectiveFilePath);
103+
}
104+
68105
[Fact]
69106
public void Upload_ParsesAgentMarkdownAndCreatesAgentFile()
70107
{
@@ -167,12 +204,14 @@ public void Upsert_WhenSandboxProfileIsKnown_PersistsAndReturnsIt()
167204

168205
private sealed class AgentRegistryFixture : IDisposable
169206
{
170-
public AgentRegistryFixture()
207+
public AgentRegistryFixture(bool useWritableOverlay = false)
171208
{
172209
Root = Path.Combine(Path.GetTempPath(), $"agent_registry_{Guid.NewGuid():N}");
173210
AgentsDirectory = Path.Combine(Root, "agents");
211+
WritableAgentsDirectory = useWritableOverlay ? Path.Combine(Root, "agent-overlays") : AgentsDirectory;
174212
SkillsDirectory = Path.Combine(Root, "skills");
175213
Directory.CreateDirectory(AgentsDirectory);
214+
Directory.CreateDirectory(WritableAgentsDirectory);
176215
Directory.CreateDirectory(Path.Combine(SkillsDirectory, "test-driven-development"));
177216
File.WriteAllText(Path.Combine(SkillsDirectory, "test-driven-development", "SKILL.md"), """
178217
---
@@ -183,8 +222,11 @@ public AgentRegistryFixture()
183222
Skill body.
184223
""");
185224

186-
Paths = new AgentRegistryPaths(AgentsDirectory, SkillsDirectory);
187-
Registry = new FileAgentRegistry(AgentsDirectory);
225+
Paths = new AgentRegistryPaths(AgentsDirectory, SkillsDirectory)
226+
{
227+
WritableAgentsDirectory = WritableAgentsDirectory
228+
};
229+
Registry = new FileAgentRegistry(Paths);
188230
Skills = new SkillRepository(SkillsDirectory);
189231
Editor = new FileAgentRegistryEditor(Paths, Registry);
190232
}
@@ -193,6 +235,8 @@ Skill body.
193235

194236
public string AgentsDirectory { get; }
195237

238+
public string WritableAgentsDirectory { get; }
239+
196240
public string SkillsDirectory { get; }
197241

198242
public AgentRegistryPaths Paths { get; }

0 commit comments

Comments
 (0)