Skip to content

Commit 89c93d1

Browse files
committed
feat(run): add runtime agent resolution from config (ADR 0058 Phase 3)
Signed-off-by: Greg Allen <gallen@redhat.com> Signed-off-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Greg Allen <gallen@redhat.com>
1 parent 774cffb commit 89c93d1

23 files changed

Lines changed: 789 additions & 227 deletions

docs/agents/README.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
# Default Agents
1+
# Agents
22

3-
Reference documentation for the default agents shipped by fullsend.
4-
All agents below are enabled by default. The set of default agents is defined by
5-
the YAML files in [`internal/scaffold/fullsend-repo/harness/`](../../internal/scaffold/fullsend-repo/harness/).
3+
Reference documentation for the agents shipped by fullsend.
4+
The default agents below are defined by the YAML files in
5+
[`internal/scaffold/fullsend-repo/harness/`](../../internal/scaffold/fullsend-repo/harness/).
6+
Custom agents can be registered via the `agents:` field in org or per-repo
7+
config (see [ADR 0058](../ADRs/0058-agent-registration.md)).
68

79
| Agent | Summary |
810
|-------|---------|
@@ -23,5 +25,9 @@ a specific agent performs a specific task. See
2325

2426
## Custom Agents
2527

26-
Support for adding your own custom agents to the fullsend pipeline is coming
27-
soon.
28+
Custom agents can be added to the fullsend pipeline via the `agents:` field in
29+
your org-level or per-repo `config.yaml`. Each entry is either a local path
30+
(relative to the fullsend directory) or a pinned HTTPS URL with an integrity
31+
hash. Config-registered agents override scaffold defaults when names collide
32+
(case-insensitive). See [ADR 0058](../ADRs/0058-agent-registration.md) for
33+
details.

docs/plans/adr-0045-forge-portable-harness-phase2.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ The `WorkflowsLayer` currently uses `scaffold.WalkFullsendRepo()` which skips `l
225225

226226
**`HarnessWrappersLayer` struct:**
227227
- Fields: `org string`, `client forge.Client`, `printer *ui.Printer`, `agents []AgentCredentials`, `commitSHA string`, `existingHarnesses map[string]bool`
228-
- `NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string) *HarnessWrappersLayer`
228+
- `NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string, configAgentNames []string) *HarnessWrappersLayer`
229229

230230
**`Install() error`:**
231231
1. For each agent in `agents`:

docs/runtimes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ The sandbox has two key directories that map to Claude Code's config levels:
4444
/sandbox/
4545
├── claude-config/ ← CLAUDE_CONFIG_DIR (personal level)
4646
│ ├── agents/
47-
│ │ └── <name>.md Agent definition (filename derived from AgentName())
47+
│ │ └── <name>.md Agent definition (filename derived from the agent name)
4848
│ ├── skills/
4949
│ │ ├── code-review/SKILL.md Built-in skills (personal level — wins on collision)
5050
│ │ ├── pr-review/SKILL.md

e2e/admin/testutil.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,6 @@ var orgPool = []string{
5656
"halfsend-04",
5757
"halfsend-05",
5858
"halfsend-06",
59-
"halfsend-07",
60-
"halfsend-08",
61-
"halfsend-09",
62-
"halfsend-10",
63-
"halfsend-11",
64-
"halfsend-12",
6559
}
6660

6761
// acquireOrg scans the pool for an unlocked org and acquires its lock.

internal/cli/admin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1854,7 +1854,7 @@ func buildLayerStack(
18541854
return layers.NewStack(
18551855
layers.NewConfigRepoLayer(org, client, cfg, printer, privateRepo),
18561856
workflowsLayer(ctx, org, client, printer, user, version, vendor, vendorCollect, direct),
1857-
layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA),
1857+
layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA, configAgentNames(cfg.Agents)),
18581858
vendorLayer(org, client, printer, vendor, vendorFn, vendorCollect, analyzeFullsendSource),
18591859
layers.NewSecretsLayer(org, client, agentCreds, printer).WithOIDCMode(),
18601860
layers.NewInferenceLayer(org, client, inferenceProvider, printer),

internal/cli/admin_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1288,7 +1288,7 @@ func TestCheckInstallScopes_SyncWithLayers(t *testing.T) {
12881288
stack := layers.NewStack(
12891289
layers.NewConfigRepoLayer("test-org", nil, emptyCfg, ui.New(&discardWriter{}), false),
12901290
layers.NewWorkflowsLayer("test-org", nil, ui.New(&discardWriter{}), "", "test-version", false),
1291-
layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev"),
1291+
layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev", nil),
12921292
layers.NewSecretsLayer("test-org", nil, nil, ui.New(&discardWriter{})),
12931293
layers.NewInferenceLayer("test-org", nil, nil, ui.New(&discardWriter{})),
12941294
layers.NewOIDCDispatchLayer("test-org", nil, nil, nil, ui.New(&discardWriter{})),

internal/cli/orgconfig.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ import (
88
"github.com/fullsend-ai/fullsend/internal/ui"
99
)
1010

11+
// configAgentNames extracts derived names from a list of agent entries.
12+
func configAgentNames(agents []config.AgentEntry) []string {
13+
names := make([]string, len(agents))
14+
for i, a := range agents {
15+
names[i] = a.DerivedName()
16+
}
17+
return names
18+
}
19+
1120
// tryLoadOrgConfig attempts to load org config from the given path.
1221
// Returns nil without error when the file is absent (best-effort).
1322
// Logs warnings via printer for non-ENOENT read errors and parse errors.

internal/cli/run.go

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
171171
}
172172

173173
// 1. Resolve and load harness.
174-
harnessPath, err := resolveHarnessPath(absFullsendDir, agentName, printer)
175-
if err != nil {
176-
return err
177-
}
178174
harnessStart := time.Now()
179-
printer.StepStart("Loading harness: " + harnessPath)
180175

181176
forgePlatform, err := detectForgePlatform(forgeFlag)
182177
if err != nil {
@@ -188,50 +183,62 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
188183
policy.Offline = rFlags.offline
189184

190185
// Best-effort org config loading — provides the allowlist for base
191-
// harness fetching. If the file is missing or unparseable we proceed
192-
// without it; HasURLReferences will enforce its presence later if needed.
186+
// harness fetching and the agent registry for config-driven resolution.
187+
// If the file is missing or unparseable we proceed without it;
188+
// HasURLReferences will enforce its presence later if needed.
193189
orgConfigPath := filepath.Join(absFullsendDir, "config.yaml")
194190
orgCfg := tryLoadOrgConfig(orgConfigPath, printer)
195191
var orgAllowlist []string
196192
if orgCfg != nil {
197193
orgAllowlist = orgCfg.AllowedRemoteResources
198194
}
199195

200-
// If the harness has a URL base and org config failed to load,
201-
// load it strictly now so LoadWithBase gets a proper error path
202-
// rather than an unhelpful "URL base requires allowed_remote_resources".
203-
if orgCfg == nil {
204-
if rawH, rawErr := harness.LoadRaw(harnessPath); rawErr == nil && rawH.Base != "" && harness.IsURL(rawH.Base) {
205-
var err error
206-
orgCfg, err = requireOrgConfig(orgConfigPath, printer)
207-
if err != nil {
208-
return err
209-
}
210-
orgAllowlist = orgCfg.AllowedRemoteResources
211-
}
212-
}
213-
214196
var composeForgeClient forge.Client
215197
if rFlags.forgeClient != nil {
216198
composeForgeClient = rFlags.forgeClient
217199
} else if token, tokenErr := resolveToken(); tokenErr == nil {
218200
composeForgeClient = gh.New(token)
219201
}
220202

221-
h, baseDeps, err := harness.LoadWithBase(ctx, harnessPath, harness.ComposeOpts{
203+
composeOpts := harness.ComposeOpts{
222204
WorkspaceRoot: absFullsendDir,
223205
FetchPolicy: policy,
224206
AuditLogPath: filepath.Join(absFullsendDir, ".fullsend-cache", "fetch-audit.jsonl"),
225207
ForgePlatform: forgePlatform,
226208
OrgAllowlist: orgAllowlist,
227209
ForgeClient: composeForgeClient,
228-
})
210+
}
211+
212+
// Resolve agent source: config agents take precedence over disk harnesses.
213+
harnessPath, fetchDeps, err := resolveAgentSource(ctx, absFullsendDir, agentName, orgCfg, composeOpts, printer)
214+
if err != nil {
215+
return err
216+
}
217+
218+
printer.StepStart("Loading harness: " + harnessPath)
219+
220+
// If the harness has a URL base and org config failed to load,
221+
// load it strictly now so LoadWithBase gets a proper error path
222+
// rather than an unhelpful "URL base requires allowed_remote_resources".
223+
if orgCfg == nil {
224+
if rawH, rawErr := harness.LoadRaw(harnessPath); rawErr == nil && rawH.Base != "" && harness.IsURL(rawH.Base) {
225+
var err error
226+
orgCfg, err = requireOrgConfig(orgConfigPath, printer)
227+
if err != nil {
228+
return err
229+
}
230+
composeOpts.OrgAllowlist = orgCfg.AllowedRemoteResources
231+
}
232+
}
233+
234+
h, baseDeps, err := harness.LoadWithBase(ctx, harnessPath, composeOpts)
229235
if err != nil {
230236
printer.StepFail("Failed to load harness")
231237
return fmt.Errorf("loading harness: %w", err)
232238
}
233239

234-
for _, dep := range baseDeps {
240+
allDeps := append(fetchDeps, baseDeps...)
241+
for _, dep := range allDeps {
235242
if dep.CacheHit {
236243
printer.StepInfo(fmt.Sprintf("Base: %s (cache hit)", dep.URL))
237244
} else {
@@ -2344,3 +2351,73 @@ func validateRepoNames(repos []string) error {
23442351
}
23452352
return nil
23462353
}
2354+
2355+
// resolveAgentSource resolves the harness path for an agent, checking
2356+
// config-registered agents before falling back to disk-based lookup.
2357+
// Returns the local filesystem path to the harness (cached for URL sources)
2358+
// and any fetch dependencies from URL-based agent resolution.
2359+
func resolveAgentSource(ctx context.Context, fullsendDir, agentName string, orgCfg *config.OrgConfig, composeOpts harness.ComposeOpts, printer *ui.Printer) (string, []harness.Dependency, error) {
2360+
if orgCfg == nil || len(orgCfg.Agents) == 0 {
2361+
path, err := resolveHarnessPath(fullsendDir, agentName, printer)
2362+
return path, nil, err
2363+
}
2364+
2365+
if err := config.ValidateAgentEntries(orgCfg.Agents, orgCfg.AllowedRemoteResources); err != nil {
2366+
return "", nil, fmt.Errorf("invalid agent config: %w", err)
2367+
}
2368+
2369+
sha := commitSHA
2370+
if sha == "dev" {
2371+
sha = ""
2372+
}
2373+
scaffoldNames, snErr := scaffold.HarnessNames()
2374+
if snErr != nil {
2375+
return "", nil, fmt.Errorf("listing scaffold harnesses: %w", snErr)
2376+
}
2377+
2378+
merged, err := config.MergedAgents(scaffoldNames, sha, orgCfg.Agents, scaffold.HarnessBaseURLWithHash)
2379+
if err != nil {
2380+
return "", nil, fmt.Errorf("building merged agent set: %w", err)
2381+
}
2382+
2383+
agent := config.LookupMergedAgent(merged, agentName)
2384+
if agent == nil || !agent.IsConfig {
2385+
path, err := resolveHarnessPath(fullsendDir, agentName, printer)
2386+
return path, nil, err
2387+
}
2388+
2389+
if harness.IsURL(agent.Source) {
2390+
printer.StepStart(fmt.Sprintf("Fetching agent harness: %s", agent.Name))
2391+
localPath, dep, err := harness.FetchAgentHarness(ctx, agent.Source, composeOpts)
2392+
if err != nil {
2393+
printer.StepFail("Failed to fetch agent harness")
2394+
return "", nil, fmt.Errorf("resolving config agent %s: %w", agent.Name, err)
2395+
}
2396+
printer.StepDone(fmt.Sprintf("Agent %s resolved from config (URL)", agent.Name))
2397+
return localPath, []harness.Dependency{dep}, nil
2398+
}
2399+
2400+
contained, err := containedLocalPath(fullsendDir, agent.Source)
2401+
if err != nil {
2402+
return "", nil, fmt.Errorf("config agent %s: %w", agent.Name, err)
2403+
}
2404+
if _, err := os.Stat(contained); err != nil {
2405+
return "", nil, fmt.Errorf("config agent %s: local path %s: %w", agent.Name, agent.Source, err)
2406+
}
2407+
printer.StepDone(fmt.Sprintf("Agent %s resolved from config (local path)", agent.Name))
2408+
return contained, nil, nil
2409+
}
2410+
2411+
// containedLocalPath resolves a relative source path against baseDir and
2412+
// verifies the result stays within baseDir. Returns an error for absolute
2413+
// paths or paths that escape via traversal.
2414+
func containedLocalPath(baseDir, source string) (string, error) {
2415+
if filepath.IsAbs(source) {
2416+
return "", fmt.Errorf("local path must be relative, not absolute")
2417+
}
2418+
resolved := filepath.Clean(filepath.Join(baseDir, source))
2419+
if rel, err := filepath.Rel(baseDir, resolved); err != nil || strings.HasPrefix(rel, "..") {
2420+
return "", fmt.Errorf("local path %q escapes fullsend directory", source)
2421+
}
2422+
return resolved, nil
2423+
}

0 commit comments

Comments
 (0)