Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions .github/workflows/reusable-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
contents: read
pull-requests: read
outputs:
stage: ${{ steps.role-check.outputs.skipped != 'true' && steps.pr-check.outputs.skipped != 'true' && steps.route.outputs.stage || '' }}
stage: ${{ steps.role-check.outputs.skipped != 'true' && steps.agent-check.outputs.skipped != 'true' && steps.pr-check.outputs.skipped != 'true' && steps.route.outputs.stage || '' }}
trigger_source: ${{ steps.route.outputs.trigger_source }}
event_payload: ${{ steps.payload.outputs.event_payload }}
steps:
Expand Down Expand Up @@ -366,6 +366,23 @@ jobs:
fi
fi

- name: Validate agents config
if: steps.route.outputs.stage != ''
run: |
set -euo pipefail
if [[ ! -f .fullsend/config.yaml ]]; then
exit 0
fi
AGENT_COUNT=$(yq '.agents | length // 0' .fullsend/config.yaml 2>/dev/null || echo "0")
if [[ "$AGENT_COUNT" == "0" ]]; then
exit 0
fi
BAD=$(yq '.agents[] | select(.enabled == false and (.name == null or .name == "")) | line' .fullsend/config.yaml 2>/dev/null || echo "")
if [[ -n "$BAD" ]]; then
echo "::error::config.yaml: disabled agent entry without a name field — add 'name:' to identify which agent to disable"
exit 1
fi

- name: Check role is enabled
id: role-check
if: steps.route.outputs.stage != ''
Expand All @@ -392,8 +409,35 @@ jobs:
fi
fi

- name: Check agent is enabled
id: agent-check
if: steps.route.outputs.stage != '' && steps.role-check.outputs.skipped != 'true' && steps.pr-check.outputs.skipped != 'true'
env:
STAGE: ${{ steps.route.outputs.stage }}
run: |
set -euo pipefail
if [[ ! -f .fullsend/config.yaml ]]; then
exit 0
fi
YQ_ERR=$(mktemp)
AGENT_ENABLED=$(yq "
.agents[] |
select((.name | downcase) == \"$STAGE\") |
.enabled
" .fullsend/config.yaml 2>"$YQ_ERR" | tail -1) || {
echo "::warning::yq failed checking agents[].enabled: $(cat "$YQ_ERR")"
rm -f "$YQ_ERR"
exit 0
}
rm -f "$YQ_ERR"
if [[ "$AGENT_ENABLED" == "false" ]]; then
echo "::notice::Stage '$STAGE' skipped — agent '$STAGE' is disabled in config"
echo "skipped=true" >> "${GITHUB_OUTPUT}"
exit 0
fi

- name: Block fork PRs for fix stage
if: steps.route.outputs.stage == 'fix' && steps.role-check.outputs.skipped != 'true' && github.event.issue.pull_request
if: steps.route.outputs.stage == 'fix' && steps.role-check.outputs.skipped != 'true' && steps.agent-check.outputs.skipped != 'true' && github.event.issue.pull_request
env:
GH_TOKEN: ${{ github.token }}
SOURCE_REPO: ${{ github.repository }}
Expand Down
6 changes: 5 additions & 1 deletion docs/ADRs/0058-agent-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,17 @@ list to both `OrgConfig` and `PerRepoConfig`. (Note: ADR 0045 Phase 4
previously removed the `agents` block from `OrgConfig`; this re-adds
a field with the same YAML key but different semantics — harness
source URLs rather than role/name/slug identity tuples.) Each entry
is a URL or local path, with an optional name override:
is a URL or local path, with an optional name override. Entries may
also set `enabled: false` to disable an agent (including scaffold
Comment thread
ggallen marked this conversation as resolved.
defaults) without removing it from configuration:

```yaml
agents:
- https://raw.githubusercontent.com/fullsend-ai/agents/<sha>/harness/triage.yaml#sha256=<hash>
- name: lint
source: harness/my-linter.yaml
- name: retro
enabled: false # suppression-only — disables scaffold default
```

`fullsend run <name>` resolves agents from config at runtime, loading
Expand Down
22 changes: 22 additions & 0 deletions docs/guides/user/customizing-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,28 @@ my-repo/
│ └── harness/code.yaml # Repo-specific harness config
```

## Disabling Agents

To disable an agent (including built-in scaffold agents) without removing
its role, add an entry with `enabled: false` in your config:

```yaml
agents:
- name: retro
Comment thread
ggallen marked this conversation as resolved.
enabled: false
```

This prevents the agent from dispatching and from resolving via
`fullsend run`. The role can stay in `defaults.roles` — only the agent
is suppressed. Omitting `enabled` (or setting it to `true`) keeps the
agent active (backward compatible).

**Important:** The `name` must match the **agent/harness name**, not the
role name. The built-in agent names are: `code`, `triage`, `review`,
`fix`, `retro`, `prioritize`. Note that the role `coder` maps to the
agent named `code` — writing `name: coder` passes validation but
disables nothing because no agent has that harness name.

## See Also

- [Default, derived, and custom agents](../../agents/topics/default-vs-custom.md) - When does configuration cross into derived or custom agent territory?
Expand Down
8 changes: 8 additions & 0 deletions docs/plans/agent-extraction-to-agents-repo.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,14 @@ agents:
- source: https://raw.githubusercontent.com/fullsend-ai/agents/<sha>/harness/prioritize.yaml#sha256=<hash>
```

To selectively disable an agent, add an `enabled: false` entry:

```yaml
agents:
- name: retro
enabled: false
```

#### 6b. Verify agent resolution

Run `fullsend agent list` from the `.fullsend` checkout to verify all
Expand Down
9 changes: 7 additions & 2 deletions docs/plans/agent-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ object form via a custom YAML unmarshaler:

```go
type AgentEntry struct {
Name string `yaml:"name,omitempty"`
Source string `yaml:"source"`
Name string `yaml:"name,omitempty"`
Source string `yaml:"source"`
Enabled *bool `yaml:"enabled,omitempty"`
}
```

When `Enabled` is nil (omitted), the entry defaults to enabled.
Setting `enabled: false` suppresses the agent from the merged set
and blocks resolution. Disabled entries must have an explicit `Name`.

`AgentEntry` implements `yaml.Unmarshaler`: if the YAML node is a
scalar string, it populates `Source` and leaves `Name` empty (derived
from the source filename at usage time). If the node is a mapping, it
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2835,6 +2835,13 @@ func resolveAgentSource(ctx context.Context, fullsendDir, agentName string, forg

agent := config.LookupMergedAgent(merged, agentName)
if agent == nil || !agent.IsConfig {
// An explicitly disabled agent must not fall through to the
// agents-repo fallback or disk lookup — that would silently
// re-enable it. Return a clear error instead.
if config.IsAgentExplicitlyDisabled(orgCfg.Agents, agentName) {
printer.StepFail(fmt.Sprintf("Agent %s is disabled in config", agentName))
return "", nil, fmt.Errorf("agent %q is explicitly disabled in config", agentName)
}
if path, deps, ok := tryAgentsRepoFallback(ctx, agentName, forgeClient, composeOpts, printer); ok {
return path, deps, nil
}
Expand Down
92 changes: 92 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,98 @@ func TestResolveAgentSource_AgentsRepoFallback_DiskFallback(t *testing.T) {
assert.Empty(t, deps)
}

func TestResolveAgentSource_DisabledAgentBlocksFallback(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
// Place a triage harness on disk to prove fallback is NOT used.
require.NoError(t, os.WriteFile(
filepath.Join(dir, "harness", "triage.yaml"),
[]byte("agent: agents/triage.md\nrole: test\n"),
0o644,
))

f := false
orgCfg := &config.OrgConfig{
Agents: []config.AgentEntry{
{Name: "triage", Enabled: &f},
},
}

printer := ui.New(io.Discard)
_, _, err := resolveAgentSource(context.Background(), dir, "triage", nil, orgCfg, harness.ComposeOpts{}, printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "explicitly disabled")
}

func TestResolveAgentSource_DisabledFirstPartyAgentBlocksFallback(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
// Place a retro harness on disk so it would resolve if fallback were allowed.
require.NoError(t, os.WriteFile(
filepath.Join(dir, "harness", "retro.yaml"),
[]byte("agent: agents/retro.md\nrole: test\n"),
0o644,
))

f := false
orgCfg := &config.OrgConfig{
Agents: []config.AgentEntry{
{Name: "retro", Enabled: &f},
},
}

fakeClient := forge.NewFakeClient()
printer := ui.New(io.Discard)
_, _, err := resolveAgentSource(context.Background(), dir, "retro", fakeClient, orgCfg, harness.ComposeOpts{}, printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "explicitly disabled")
}

func TestResolveAgentSource_SuppressionOnlyEntryBlocksFallback(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(dir, "harness", "retro.yaml"),
[]byte("agent: agents/retro.md\nrole: test\n"),
0o644,
))

// Suppression-only entry: enabled=false, no source.
f := false
orgCfg := &config.OrgConfig{
Agents: []config.AgentEntry{
{Name: "retro", Enabled: &f},
},
}

printer := ui.New(io.Discard)
_, _, err := resolveAgentSource(context.Background(), dir, "retro", nil, orgCfg, harness.ComposeOpts{}, printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "explicitly disabled")
}

func TestResolveAgentSource_EnabledAgentStillResolves(t *testing.T) {
dir := canonTempDir(t)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(dir, "harness", "custom.yaml"),
[]byte("agent: agents/custom.md\nrole: test\n"),
0o644,
))

tr := true
orgCfg := &config.OrgConfig{
Agents: []config.AgentEntry{
{Name: "custom", Source: "harness/custom.yaml", Enabled: &tr},
},
}

printer := ui.New(io.Discard)
path, _, err := resolveAgentSource(context.Background(), dir, "custom", nil, orgCfg, harness.ComposeOpts{}, printer)
require.NoError(t, err)
assert.Contains(t, path, "custom.yaml")
}

func TestTryAgentsRepoFallback_UnknownAgent(t *testing.T) {
fakeClient := forge.NewFakeClient()
printer := ui.New(io.Discard)
Expand Down
35 changes: 33 additions & 2 deletions internal/config/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,25 @@ func MergedAgents(scaffoldNames []string, commitSHA string, configAgents []Agent
}
}

inOrder := make(map[string]bool, len(order))
for _, key := range order {
inOrder[key] = true
}

for _, entry := range configAgents {
name := entry.DerivedName()
lower := strings.ToLower(name)
if _, exists := byName[lower]; !exists {

// A disabled entry suppresses the agent from the merged set.
// If it overrides a scaffold entry, that scaffold entry is removed.
if !entry.IsEnabled() {
delete(byName, lower)
Comment thread
ggallen marked this conversation as resolved.
continue
}

if !inOrder[lower] {
order = append(order, lower)
inOrder[lower] = true
}
byName[lower] = &MergedAgent{
Name: name,
Expand All @@ -57,7 +71,9 @@ func MergedAgents(scaffoldNames []string, commitSHA string, configAgents []Agent

result := make([]MergedAgent, 0, len(byName))
for _, key := range order {
result = append(result, *byName[key])
if agent, ok := byName[key]; ok {
result = append(result, *agent)
}
}
sort.Slice(result, func(i, j int) bool {
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
Expand All @@ -66,6 +82,21 @@ func MergedAgents(scaffoldNames []string, commitSHA string, configAgents []Agent
return result, nil
}

// IsAgentExplicitlyDisabled returns true if the agent name appears in the
// config entries with Enabled explicitly set to false. This allows callers
// to distinguish "agent is explicitly disabled" from "agent is not in the
// config at all" — a distinction that MergedAgents erases (both cases
// result in the agent being absent from the merged set).
func IsAgentExplicitlyDisabled(agents []AgentEntry, name string) bool {
lower := strings.ToLower(name)
for _, entry := range agents {
if strings.ToLower(entry.DerivedName()) == lower && !entry.IsEnabled() {
return true
}
}
return false
}

// LookupMergedAgent finds an agent by name (case-insensitive) in the merged set.
// Returns nil if not found.
func LookupMergedAgent(agents []MergedAgent, name string) *MergedAgent {
Expand Down
Loading
Loading