Skip to content

Commit c3093c2

Browse files
committed
feat(cli): add runtime fallback to agents repo for unconfigured agents
When an agent is not registered in config.yaml, resolveAgentSource now attempts to fetch the latest harness from fullsend-ai/agents before falling back to the scaffold-embedded harness on disk. This allows existing fullsend users to transparently use the extracted agents repository without any config changes. The fallback resolves the main branch HEAD SHA via the GitHub API, constructs a pinned URL with integrity hash, and uses the existing FetchAgentHarness path for full security validation. Only known first-party agents (triage, code, fix, review, retro, prioritize) are eligible for fallback. The fallback is skipped in offline mode or when no git token is available. Signed-off-by: Greg Allen <gallen@redhat.com> Signed-off-by: Claude <noreply@anthropic.com> Signed-off-by: Greg Allen <gallen@redhat.com>
1 parent b19e1ef commit c3093c2

2 files changed

Lines changed: 139 additions & 8 deletions

File tree

internal/cli/run.go

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,28 @@ const (
4949

5050
// metricsFile is the filename written to the run directory with behavioral metrics.
5151
metricsFile = "metrics.json"
52+
53+
// Default agents repository for runtime fallback when an agent is not
54+
// registered in config. The binary resolves the latest commit SHA from
55+
// this repo and fetches the harness dynamically.
56+
defaultAgentsRepoOwner = "fullsend-ai"
57+
defaultAgentsRepoName = "agents"
58+
defaultAgentsRepoBranch = "main"
59+
agentsRepoURLPrefix = "https://raw.githubusercontent.com/fullsend-ai/agents/"
5260
)
5361

62+
// agentsRepoKnownAgents is the set of first-party agents available in the
63+
// fullsend-ai/agents repository. The fallback only attempts resolution for
64+
// agents in this set — custom agents are not tried against the agents repo.
65+
var agentsRepoKnownAgents = map[string]bool{
66+
"triage": true,
67+
"code": true,
68+
"fix": true,
69+
"review": true,
70+
"retro": true,
71+
"prioritize": true,
72+
}
73+
5474
// statusMintToken is the test seam for minting tokens. Shared by both
5575
// setupStatusNotifier (status comment tokens) and mintAgentToken (agent
5676
// runtime tokens). Tests that override it affect both paths.
@@ -224,7 +244,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
224244
}
225245

226246
// Resolve agent source: config agents take precedence over disk harnesses.
227-
harnessPath, fetchDeps, err := resolveAgentSource(ctx, absFullsendDir, agentName, orgCfg, composeOpts, printer)
247+
harnessPath, fetchDeps, err := resolveAgentSource(ctx, absFullsendDir, agentName, composeGitToken, orgCfg, composeOpts, printer)
228248
if err != nil {
229249
return err
230250
}
@@ -2556,11 +2576,15 @@ func validateRepoNames(repos []string) error {
25562576
}
25572577

25582578
// resolveAgentSource resolves the harness path for an agent, checking
2559-
// config-registered agents before falling back to disk-based lookup.
2579+
// config-registered agents first, then falling back to the agents repo
2580+
// (fullsend-ai/agents), then to disk-based lookup.
25602581
// Returns the local filesystem path to the harness (cached for URL sources)
25612582
// and any fetch dependencies from URL-based agent resolution.
2562-
func resolveAgentSource(ctx context.Context, fullsendDir, agentName string, orgCfg *config.OrgConfig, composeOpts harness.ComposeOpts, printer *ui.Printer) (string, []harness.Dependency, error) {
2583+
func resolveAgentSource(ctx context.Context, fullsendDir, agentName, gitToken string, orgCfg *config.OrgConfig, composeOpts harness.ComposeOpts, printer *ui.Printer) (string, []harness.Dependency, error) {
25632584
if orgCfg == nil || len(orgCfg.Agents) == 0 {
2585+
if path, deps, ok := tryAgentsRepoFallback(ctx, agentName, gitToken, composeOpts, printer); ok {
2586+
return path, deps, nil
2587+
}
25642588
path, err := resolveHarnessPath(fullsendDir, agentName, printer)
25652589
return path, nil, err
25662590
}
@@ -2585,6 +2609,9 @@ func resolveAgentSource(ctx context.Context, fullsendDir, agentName string, orgC
25852609

25862610
agent := config.LookupMergedAgent(merged, agentName)
25872611
if agent == nil || !agent.IsConfig {
2612+
if path, deps, ok := tryAgentsRepoFallback(ctx, agentName, gitToken, composeOpts, printer); ok {
2613+
return path, deps, nil
2614+
}
25882615
path, err := resolveHarnessPath(fullsendDir, agentName, printer)
25892616
return path, nil, err
25902617
}
@@ -2611,6 +2638,61 @@ func resolveAgentSource(ctx context.Context, fullsendDir, agentName string, orgC
26112638
return contained, nil, nil
26122639
}
26132640

2641+
// tryAgentsRepoFallback attempts to resolve an agent from the default agents
2642+
// repository (fullsend-ai/agents) by fetching the latest harness from the
2643+
// main branch. Returns (path, deps, true) on success, or ("", nil, false)
2644+
// if the fallback should be skipped (offline, no token, agent not found, etc.).
2645+
// All errors are non-fatal — the caller falls through to disk-based lookup.
2646+
func tryAgentsRepoFallback(ctx context.Context, agentName, gitToken string, composeOpts harness.ComposeOpts, printer *ui.Printer) (string, []harness.Dependency, bool) {
2647+
if !agentsRepoKnownAgents[strings.ToLower(agentName)] {
2648+
return "", nil, false
2649+
}
2650+
if composeOpts.FetchPolicy.Offline {
2651+
return "", nil, false
2652+
}
2653+
if gitToken == "" {
2654+
return "", nil, false
2655+
}
2656+
2657+
client := gh.New(gitToken)
2658+
branchSHA, err := client.GetBranchRef(ctx, defaultAgentsRepoOwner, defaultAgentsRepoName, defaultAgentsRepoBranch)
2659+
if err != nil {
2660+
printer.StepWarn(fmt.Sprintf("Could not resolve %s/%s@%s: %v", defaultAgentsRepoOwner, defaultAgentsRepoName, defaultAgentsRepoBranch, err))
2661+
return "", nil, false
2662+
}
2663+
2664+
rawURL := agentsRepoURLPrefix + branchSHA + "/harness/" + agentName + ".yaml"
2665+
2666+
content, err := fetch.FetchURL(ctx, rawURL, composeOpts.FetchPolicy)
2667+
if err != nil {
2668+
return "", nil, false
2669+
}
2670+
2671+
hash := fetch.ComputeSHA256(content)
2672+
pinnedURL := rawURL + "#sha256=" + hash
2673+
2674+
allowlist := composeOpts.OrgAllowlist
2675+
if len(allowlist) == 0 {
2676+
allowlist = config.DefaultAllowedRemoteResources()
2677+
}
2678+
2679+
printer.StepStart(fmt.Sprintf("Fetching agent %s from %s/%s@%s", agentName, defaultAgentsRepoOwner, defaultAgentsRepoName, branchSHA[:12]))
2680+
localPath, dep, err := harness.FetchAgentHarness(ctx, pinnedURL, harness.ComposeOpts{
2681+
WorkspaceRoot: composeOpts.WorkspaceRoot,
2682+
FetchPolicy: composeOpts.FetchPolicy,
2683+
AuditLogPath: composeOpts.AuditLogPath,
2684+
OrgAllowlist: allowlist,
2685+
TreeFetcher: composeOpts.TreeFetcher,
2686+
GitToken: composeOpts.GitToken,
2687+
})
2688+
if err != nil {
2689+
printer.StepWarn(fmt.Sprintf("Agents repo fallback failed for %s: %v", agentName, err))
2690+
return "", nil, false
2691+
}
2692+
printer.StepDone(fmt.Sprintf("Agent %s resolved from %s/%s (latest)", agentName, defaultAgentsRepoOwner, defaultAgentsRepoName))
2693+
return localPath, []harness.Dependency{dep}, true
2694+
}
2695+
26142696
// containedLocalPath resolves a relative source path against baseDir and
26152697
// verifies the result stays within baseDir. Returns an error for absolute
26162698
// paths or paths that escape via traversal.

internal/cli/run_test.go

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,7 @@ func TestResolveAgentSource_NoConfig(t *testing.T) {
636636
))
637637

638638
printer := ui.New(io.Discard)
639-
path, deps, err := resolveAgentSource(context.Background(), dir, "code", nil, harness.ComposeOpts{}, printer)
639+
path, deps, err := resolveAgentSource(context.Background(), dir, "code", "", nil, harness.ComposeOpts{}, printer)
640640
require.NoError(t, err)
641641
assert.Contains(t, path, "code.yaml")
642642
assert.Empty(t, deps)
@@ -659,7 +659,7 @@ func TestResolveAgentSource_ConfigLocalPath(t *testing.T) {
659659
}
660660

661661
printer := ui.New(io.Discard)
662-
path, deps, err := resolveAgentSource(context.Background(), dir, "custom", orgCfg, harness.ComposeOpts{}, printer)
662+
path, deps, err := resolveAgentSource(context.Background(), dir, "custom", "", orgCfg, harness.ComposeOpts{}, printer)
663663
require.NoError(t, err)
664664
assert.Equal(t, filepath.Join(dir, "harness", "custom.yaml"), path)
665665
assert.Empty(t, deps)
@@ -677,7 +677,7 @@ func TestResolveAgentSource_ConfigLocalPathNotFound(t *testing.T) {
677677
}
678678

679679
printer := ui.New(io.Discard)
680-
_, _, err := resolveAgentSource(context.Background(), dir, "missing", orgCfg, harness.ComposeOpts{}, printer)
680+
_, _, err := resolveAgentSource(context.Background(), dir, "missing", "", orgCfg, harness.ComposeOpts{}, printer)
681681
require.Error(t, err)
682682
assert.Contains(t, err.Error(), "config agent missing")
683683
}
@@ -693,7 +693,7 @@ func TestResolveAgentSource_ConfigLocalPathAbsoluteRejected(t *testing.T) {
693693
}
694694

695695
printer := ui.New(io.Discard)
696-
_, _, err := resolveAgentSource(context.Background(), dir, "evil", orgCfg, harness.ComposeOpts{}, printer)
696+
_, _, err := resolveAgentSource(context.Background(), dir, "evil", "", orgCfg, harness.ComposeOpts{}, printer)
697697
require.Error(t, err)
698698
assert.Contains(t, err.Error(), "absolute paths")
699699
}
@@ -709,11 +709,60 @@ func TestResolveAgentSource_ConfigLocalPathTraversalRejected(t *testing.T) {
709709
}
710710

711711
printer := ui.New(io.Discard)
712-
_, _, err := resolveAgentSource(context.Background(), dir, "passwd", orgCfg, harness.ComposeOpts{}, printer)
712+
_, _, err := resolveAgentSource(context.Background(), dir, "passwd", "", orgCfg, harness.ComposeOpts{}, printer)
713713
require.Error(t, err)
714714
assert.Contains(t, err.Error(), "path traversal")
715715
}
716716

717+
func TestResolveAgentSource_AgentsRepoFallback_UnknownAgent(t *testing.T) {
718+
dir := t.TempDir()
719+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
720+
721+
printer := ui.New(io.Discard)
722+
_, _, err := resolveAgentSource(context.Background(), dir, "nonexistent", "fake-token", nil, harness.ComposeOpts{}, printer)
723+
require.Error(t, err)
724+
assert.Contains(t, err.Error(), "harness file not found")
725+
}
726+
727+
func TestResolveAgentSource_AgentsRepoFallback_Offline(t *testing.T) {
728+
dir := t.TempDir()
729+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
730+
731+
printer := ui.New(io.Discard)
732+
opts := harness.ComposeOpts{
733+
FetchPolicy: fetch.FetchPolicy{Offline: true},
734+
}
735+
_, _, err := resolveAgentSource(context.Background(), dir, "triage", "fake-token", nil, opts, printer)
736+
require.Error(t, err)
737+
assert.Contains(t, err.Error(), "harness file not found")
738+
}
739+
740+
func TestResolveAgentSource_AgentsRepoFallback_NoToken(t *testing.T) {
741+
dir := t.TempDir()
742+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
743+
744+
printer := ui.New(io.Discard)
745+
_, _, err := resolveAgentSource(context.Background(), dir, "triage", "", nil, harness.ComposeOpts{}, printer)
746+
require.Error(t, err)
747+
assert.Contains(t, err.Error(), "harness file not found")
748+
}
749+
750+
func TestResolveAgentSource_AgentsRepoFallback_DiskWins(t *testing.T) {
751+
dir := t.TempDir()
752+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
753+
require.NoError(t, os.WriteFile(
754+
filepath.Join(dir, "harness", "triage.yaml"),
755+
[]byte("agent: agents/triage.md\nrole: test\n"),
756+
0o644,
757+
))
758+
759+
printer := ui.New(io.Discard)
760+
path, deps, err := resolveAgentSource(context.Background(), dir, "triage", "", nil, harness.ComposeOpts{}, printer)
761+
require.NoError(t, err)
762+
assert.Contains(t, path, "triage.yaml")
763+
assert.Empty(t, deps)
764+
}
765+
717766
func TestContainedLocalPath_Valid(t *testing.T) {
718767
dir := t.TempDir()
719768
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))

0 commit comments

Comments
 (0)