Skip to content

Commit afb34de

Browse files
committed
feat(run): add runtime agent resolution from config (ADR 0058 Phase 3)
When agents are registered in config.yaml, `fullsend run` now resolves them at runtime instead of requiring on-disk wrapper harnesses: - URL sources are fetched, integrity-verified, and cached via the existing ADR 0038 fetch infrastructure - Local path sources are resolved relative to the fullsend directory - Scaffold fallback is preserved when no config entry matches The harness wrapper layer now skips generating wrappers for config-driven agents since they are resolved at runtime. 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 00fca1e commit afb34de

10 files changed

Lines changed: 642 additions & 63 deletions

File tree

internal/cli/admin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1852,7 +1852,7 @@ func buildLayerStack(
18521852
return layers.NewStack(
18531853
layers.NewConfigRepoLayer(org, client, cfg, printer, privateRepo),
18541854
workflowsLayer(ctx, org, client, printer, user, version, vendor, vendorCollect, direct),
1855-
layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA),
1855+
layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA, configAgentNames(cfg.Agents)),
18561856
vendorLayer(org, client, printer, vendor, vendorFn, vendorCollect, analyzeFullsendSource),
18571857
layers.NewSecretsLayer(org, client, agentCreds, printer).WithOIDCMode(),
18581858
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
@@ -1278,7 +1278,7 @@ func TestCheckInstallScopes_SyncWithLayers(t *testing.T) {
12781278
stack := layers.NewStack(
12791279
layers.NewConfigRepoLayer("test-org", nil, emptyCfg, ui.New(&discardWriter{}), false),
12801280
layers.NewWorkflowsLayer("test-org", nil, ui.New(&discardWriter{}), "", "test-version", false),
1281-
layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev"),
1281+
layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev", nil),
12821282
layers.NewSecretsLayer("test-org", nil, nil, ui.New(&discardWriter{})),
12831283
layers.NewInferenceLayer("test-org", nil, nil, ui.New(&discardWriter{})),
12841284
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: 77 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,49 @@ 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+
sha := commitSHA
2366+
if sha == "dev" {
2367+
sha = ""
2368+
}
2369+
scaffoldNames, _ := scaffold.HarnessNames()
2370+
2371+
merged, err := config.MergedAgents(scaffoldNames, sha, orgCfg.Agents, scaffold.HarnessBaseURLWithHash)
2372+
if err != nil {
2373+
return "", nil, fmt.Errorf("building merged agent set: %w", err)
2374+
}
2375+
2376+
agent := config.LookupMergedAgent(merged, agentName)
2377+
if agent == nil || !agent.IsConfig {
2378+
path, err := resolveHarnessPath(fullsendDir, agentName, printer)
2379+
return path, nil, err
2380+
}
2381+
2382+
if harness.IsURL(agent.Source) {
2383+
printer.StepStart(fmt.Sprintf("Fetching agent harness: %s", agent.Name))
2384+
localPath, dep, err := harness.FetchAgentHarness(ctx, agent.Source, composeOpts)
2385+
if err != nil {
2386+
printer.StepFail("Failed to fetch agent harness")
2387+
return "", nil, fmt.Errorf("resolving config agent %s: %w", agent.Name, err)
2388+
}
2389+
printer.StepDone(fmt.Sprintf("Agent %s resolved from config (URL)", agent.Name))
2390+
return localPath, []harness.Dependency{dep}, nil
2391+
}
2392+
2393+
localPath := filepath.Join(fullsendDir, agent.Source)
2394+
if _, err := os.Stat(localPath); err != nil {
2395+
return "", nil, fmt.Errorf("config agent %s: local path %s: %w", agent.Name, agent.Source, err)
2396+
}
2397+
printer.StepDone(fmt.Sprintf("Agent %s resolved from config (local path)", agent.Name))
2398+
return localPath, nil, nil
2399+
}

internal/cli/run_test.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/stretchr/testify/require"
2222

2323
"github.com/fullsend-ai/fullsend/internal/binary"
24+
"github.com/fullsend-ai/fullsend/internal/config"
2425
"github.com/fullsend-ai/fullsend/internal/fetch"
2526
"github.com/fullsend-ai/fullsend/internal/fetchsvc"
2627
"github.com/fullsend-ai/fullsend/internal/forge"
@@ -478,6 +479,209 @@ func TestRunCommand_HasEnvFileFlag(t *testing.T) {
478479
assert.Equal(t, []string{"/tmp/a.env", "/tmp/b.env"}, val)
479480
}
480481

482+
func TestRunAgent_ConfigAgentLocalPath(t *testing.T) {
483+
dir := t.TempDir()
484+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
485+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755))
486+
487+
require.NoError(t, os.WriteFile(
488+
filepath.Join(dir, "agents", "custom.md"),
489+
[]byte("You are a custom agent."),
490+
0o644,
491+
))
492+
require.NoError(t, os.WriteFile(
493+
filepath.Join(dir, "harness", "custom.yaml"),
494+
[]byte("agent: agents/custom.md\nrole: test\n"),
495+
0o644,
496+
))
497+
require.NoError(t, os.WriteFile(
498+
filepath.Join(dir, "config.yaml"),
499+
[]byte("agents:\n - harness/custom.yaml\nallowed_remote_resources:\n - \"https://example.com/\"\n"),
500+
0o644,
501+
))
502+
503+
rFlags := resolveFlags{maxDepth: 10, maxResources: 50}
504+
printer := ui.New(io.Discard)
505+
repoDir := t.TempDir()
506+
err := runAgent(context.Background(), "custom", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false)
507+
require.Error(t, err)
508+
assert.Contains(t, err.Error(), "openshell")
509+
}
510+
511+
func TestRunAgent_ConfigAgentURL(t *testing.T) {
512+
harnessContent := []byte("agent: agents/remote.md\nrole: test\n")
513+
harnessHash := fetch.ComputeSHA256(harnessContent)
514+
515+
srv, policy := newLockTestServer(t, map[string][]byte{
516+
"/harness/triage.yaml": harnessContent,
517+
})
518+
519+
dir := t.TempDir()
520+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
521+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755))
522+
523+
require.NoError(t, os.WriteFile(
524+
filepath.Join(dir, "agents", "remote.md"),
525+
[]byte("You are a remote agent."),
526+
0o644,
527+
))
528+
require.NoError(t, os.WriteFile(
529+
filepath.Join(dir, "config.yaml"),
530+
[]byte(fmt.Sprintf("agents:\n - \"%s/harness/triage.yaml#sha256=%s\"\nallowed_remote_resources:\n - \"%s/\"\n", srv.URL, harnessHash, srv.URL)),
531+
0o644,
532+
))
533+
534+
fetch.DefaultPolicy = policy
535+
defer func() { fetch.DefaultPolicy = fetch.FetchPolicy{} }()
536+
537+
rFlags := resolveFlags{maxDepth: 10, maxResources: 50}
538+
printer := ui.New(io.Discard)
539+
repoDir := t.TempDir()
540+
err := runAgent(context.Background(), "triage", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false)
541+
require.Error(t, err)
542+
assert.Contains(t, err.Error(), "openshell")
543+
}
544+
545+
func TestRunAgent_ConfigAgentOverridesScaffold(t *testing.T) {
546+
// When config has an agent with the same name as a scaffold agent,
547+
// the config source is used instead of the scaffold wrapper.
548+
dir := t.TempDir()
549+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
550+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755))
551+
552+
require.NoError(t, os.WriteFile(
553+
filepath.Join(dir, "agents", "code.md"),
554+
[]byte("You are a custom code agent."),
555+
0o644,
556+
))
557+
// Config-driven local path agent named "code" — should take precedence
558+
// over any scaffold "code" harness wrapper.
559+
require.NoError(t, os.WriteFile(
560+
filepath.Join(dir, "harness", "code.yaml"),
561+
[]byte("agent: agents/code.md\nrole: test\n"),
562+
0o644,
563+
))
564+
require.NoError(t, os.WriteFile(
565+
filepath.Join(dir, "config.yaml"),
566+
[]byte("agents:\n - harness/code.yaml\nallowed_remote_resources:\n - \"https://example.com/\"\n"),
567+
0o644,
568+
))
569+
570+
rFlags := resolveFlags{maxDepth: 10, maxResources: 50}
571+
printer := ui.New(io.Discard)
572+
repoDir := t.TempDir()
573+
err := runAgent(context.Background(), "code", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false)
574+
require.Error(t, err)
575+
assert.Contains(t, err.Error(), "openshell")
576+
}
577+
578+
func TestRunAgent_ScaffoldFallback(t *testing.T) {
579+
// When config has agents but the requested agent is not in config,
580+
// fall back to disk-based resolution.
581+
dir := t.TempDir()
582+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
583+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755))
584+
585+
require.NoError(t, os.WriteFile(
586+
filepath.Join(dir, "agents", "code.md"),
587+
[]byte("You are a coding agent."),
588+
0o644,
589+
))
590+
require.NoError(t, os.WriteFile(
591+
filepath.Join(dir, "harness", "code.yaml"),
592+
[]byte("agent: agents/code.md\nrole: test\n"),
593+
0o644,
594+
))
595+
// Config has agents but "code" is not among them — should fall back to disk.
596+
require.NoError(t, os.WriteFile(
597+
filepath.Join(dir, "config.yaml"),
598+
[]byte("agents:\n - harness/other.yaml\nallowed_remote_resources:\n - \"https://example.com/\"\n"),
599+
0o644,
600+
))
601+
602+
rFlags := resolveFlags{maxDepth: 10, maxResources: 50}
603+
printer := ui.New(io.Discard)
604+
repoDir := t.TempDir()
605+
err := runAgent(context.Background(), "code", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false)
606+
require.Error(t, err)
607+
assert.Contains(t, err.Error(), "openshell")
608+
}
609+
610+
func TestRunAgent_UnknownAgentName(t *testing.T) {
611+
dir := t.TempDir()
612+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
613+
614+
// Config has agents but "nonexistent" is not among them, and no file on disk.
615+
require.NoError(t, os.WriteFile(
616+
filepath.Join(dir, "config.yaml"),
617+
[]byte("agents:\n - harness/other.yaml\nallowed_remote_resources:\n - \"https://example.com/\"\n"),
618+
0o644,
619+
))
620+
621+
rFlags := resolveFlags{maxDepth: 10, maxResources: 50}
622+
printer := ui.New(io.Discard)
623+
repoDir := t.TempDir()
624+
err := runAgent(context.Background(), "nonexistent", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false)
625+
require.Error(t, err)
626+
assert.Contains(t, err.Error(), "harness file not found")
627+
}
628+
629+
func TestResolveAgentSource_NoConfig(t *testing.T) {
630+
dir := t.TempDir()
631+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
632+
require.NoError(t, os.WriteFile(
633+
filepath.Join(dir, "harness", "code.yaml"),
634+
[]byte("agent: agents/code.md\nrole: test\n"),
635+
0o644,
636+
))
637+
638+
printer := ui.New(io.Discard)
639+
path, deps, err := resolveAgentSource(context.Background(), dir, "code", nil, harness.ComposeOpts{}, printer)
640+
require.NoError(t, err)
641+
assert.Contains(t, path, "code.yaml")
642+
assert.Empty(t, deps)
643+
}
644+
645+
func TestResolveAgentSource_ConfigLocalPath(t *testing.T) {
646+
dir := t.TempDir()
647+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
648+
require.NoError(t, os.WriteFile(
649+
filepath.Join(dir, "harness", "custom.yaml"),
650+
[]byte("agent: agents/custom.md\nrole: test\n"),
651+
0o644,
652+
))
653+
654+
orgCfg := &config.OrgConfig{
655+
Agents: []config.AgentEntry{
656+
{Source: "harness/custom.yaml"},
657+
},
658+
AllowedRemoteResources: []string{"https://example.com/"},
659+
}
660+
661+
printer := ui.New(io.Discard)
662+
path, deps, err := resolveAgentSource(context.Background(), dir, "custom", orgCfg, harness.ComposeOpts{}, printer)
663+
require.NoError(t, err)
664+
assert.Equal(t, filepath.Join(dir, "harness", "custom.yaml"), path)
665+
assert.Empty(t, deps)
666+
}
667+
668+
func TestResolveAgentSource_ConfigLocalPathNotFound(t *testing.T) {
669+
dir := t.TempDir()
670+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
671+
672+
orgCfg := &config.OrgConfig{
673+
Agents: []config.AgentEntry{
674+
{Source: "harness/missing.yaml"},
675+
},
676+
AllowedRemoteResources: []string{"https://example.com/"},
677+
}
678+
679+
printer := ui.New(io.Discard)
680+
_, _, err := resolveAgentSource(context.Background(), dir, "missing", orgCfg, harness.ComposeOpts{}, printer)
681+
require.Error(t, err)
682+
assert.Contains(t, err.Error(), "config agent missing")
683+
}
684+
481685
func TestApplySandboxImageOverride_Applied(t *testing.T) {
482686
t.Setenv("FULLSEND_SANDBOX_IMAGE", "ghcr.io/fullsend-ai/fullsend-sandbox:dev")
483687

0 commit comments

Comments
 (0)