Skip to content

Commit 825d21b

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 ea09be6 commit 825d21b

13 files changed

Lines changed: 693 additions & 69 deletions

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 DerivedName())
4848
│ ├── skills/
4949
│ │ ├── code-review/SKILL.md Built-in skills (personal level — wins on collision)
5050
│ │ ├── pr-review/SKILL.md

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: 90 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,62 @@ 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+
if filepath.IsAbs(agent.Source) {
2401+
return "", nil, fmt.Errorf("config agent %s: local path must be relative, not absolute", agent.Name)
2402+
}
2403+
localPath := filepath.Clean(filepath.Join(fullsendDir, agent.Source))
2404+
if rel, err := filepath.Rel(fullsendDir, localPath); err != nil || strings.HasPrefix(rel, "..") {
2405+
return "", nil, fmt.Errorf("config agent %s: local path %q escapes fullsend directory", agent.Name, agent.Source)
2406+
}
2407+
if _, err := os.Stat(localPath); err != nil {
2408+
return "", nil, fmt.Errorf("config agent %s: local path %s: %w", agent.Name, agent.Source, err)
2409+
}
2410+
printer.StepDone(fmt.Sprintf("Agent %s resolved from config (local path)", agent.Name))
2411+
return localPath, nil, nil
2412+
}

0 commit comments

Comments
 (0)