Skip to content
Merged
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
70 changes: 68 additions & 2 deletions .github/workflows/reusable-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,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 @@ -373,6 +373,44 @@ 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
AGENTS_TYPE=$(yq '.agents | type' .fullsend/config.yaml 2>/dev/null || echo "")
case "$AGENTS_TYPE" in
"!!null"|"") exit 0 ;;
"!!seq") ;;
*) echo "::error::config.yaml: 'agents' must be a list (got $AGENTS_TYPE)"; exit 1 ;;
esac
AGENT_COUNT=$(yq '.agents | length' .fullsend/config.yaml 2>/dev/null || echo "0")
if [[ "$AGENT_COUNT" == "0" ]]; then
exit 0
fi
BAD_DISABLED=$(yq '.agents[] | select(.enabled == false and (.name == null or .name == "")) | line' .fullsend/config.yaml 2>/dev/null || echo "")
Comment thread
ggallen marked this conversation as resolved.
if [[ -n "$BAD_DISABLED" ]]; then
echo "::error::config.yaml: disabled agent entry without a name field — add 'name:' to identify which agent to disable"
exit 1
fi
BAD_SOURCE=$(yq '.agents[] | select(type == "!!map" and .enabled != false and (.source == null or .source == "")) | line' .fullsend/config.yaml 2>/dev/null || echo "")
if [[ -n "$BAD_SOURCE" ]]; then
echo "::error::config.yaml: enabled agent entry without a source field"
exit 1
fi
Comment thread
ggallen marked this conversation as resolved.
DUPES_ENABLED=$(yq '.agents[] | select(type == "!!map" and .enabled != false) | (.name // "") | select(. != "") | downcase' .fullsend/config.yaml 2>/dev/null | sort | uniq -d)
DUPES_DISABLED=$(yq '.agents[] | select(type == "!!map" and .enabled == false) | (.name // "") | select(. != "") | downcase' .fullsend/config.yaml 2>/dev/null | sort | uniq -d)
if [[ -n "$DUPES_ENABLED" ]]; then
echo "::error::config.yaml: duplicate enabled agent name(s): ${DUPES_ENABLED}"
exit 1
fi
if [[ -n "$DUPES_DISABLED" ]]; then
echo "::error::config.yaml: duplicate disabled agent name(s): ${DUPES_DISABLED}"
exit 1
fi

- name: Check role is enabled
id: role-check
if: steps.route.outputs.stage != ''
Expand All @@ -399,9 +437,37 @@ 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 "
Comment thread
ggallen marked this conversation as resolved.
.agents[] |
select((.name | downcase) == \"$STAGE\") |
.enabled
" .fullsend/config.yaml 2>"$YQ_ERR" | tail -1) || {
echo "::warning::yq failed checking agents[].enabled — see logs"
cat "$YQ_ERR" >&2
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: Resolve PR head for issue_comment events
id: pr-head
if: steps.route.outputs.stage != '' && steps.role-check.outputs.skipped != 'true' && steps.pr-check.outputs.skipped != 'true' && github.event_name == 'issue_comment' && github.event.issue.pull_request
if: steps.route.outputs.stage != '' && steps.role-check.outputs.skipped != 'true' && steps.agent-check.outputs.skipped != 'true' && steps.pr-check.outputs.skipped != 'true' && github.event_name == 'issue_comment' && 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
2 changes: 1 addition & 1 deletion docs/cli/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ fullsend agent list --fullsend-dir .fullsend
|------|---------|-------------|
| `--fullsend-dir` | | Base directory containing the `.fullsend` layout (required) |

Read-only. Displays a table with `NAME` and `SOURCE` columns. For URL agents, the `#sha256=...` integrity hash suffix is stripped from the displayed source for readability.
Read-only. Displays a table with `NAME` and `SOURCE` columns. For URL agents, the `#sha256=...` integrity hash suffix is stripped from the displayed source for readability. Disabled agents (`enabled: false`) are included in the listing.

Example output:
```
Expand Down
1 change: 1 addition & 0 deletions docs/guides/user/bring-your-own-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ repos:
- URL entries are automatically pinned with `#sha256=...` by `fullsend agent add`.
- URLs must be covered by `allowed_remote_resources` in the same config.
- On name collision, config-registered agents take precedence over built-in agents.
- Individual agents can be disabled with `enabled: false` — see [Disabling Agents](customizing-agents.md#disabling-agents).
- Per-repo config is read from the **base branch**, not from PR branches.

## Migrating from `customized/`
Expand Down
34 changes: 34 additions & 0 deletions docs/guides/user/customizing-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,40 @@ 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).

When multiple entries share a name, the last writer wins. This allows a
disable-then-enable pattern to replace a default agent with a custom one:

```yaml
agents:
- name: retro
enabled: false
- name: retro
source: harness/custom-retro.yaml
enabled: true
```

**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

- [Bring Your Own Agent](bring-your-own-agent.md) - Building and registering custom agents from scratch
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
35 changes: 35 additions & 0 deletions e2e/behaviour/features/dispatch/dispatch.feature
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ Feature: Harness CEL dispatch
Then the harness "pr-ping" workflow completes successfully
And the agent will succeed to Prove PR execution

Scenario: Disabled harness is not dispatched while enabled one triggers
Comment thread
ggallen marked this conversation as resolved.
Given a custom harness "enabled-ping" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-enabled-ping
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-enabled-test"
"""
And a disabled custom harness "disabled-ping" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-disabled-ping
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-enabled-test"
"""
And a dummy agent that would:
| description | op | args |
| Prove execution | write_fixture| output/dispatch-enabled-ok.json, fixtures/dispatch/ok.json |
And an issue
When the issue is labeled "ready-for-enabled-test"
Then the harness "enabled-ping" workflow completes successfully
And the agent will succeed to Prove execution
And the harness "enabled-ping" was dispatched exactly 1 time
And the harness "disabled-ping" agent did not run
Comment thread
ggallen marked this conversation as resolved.

Scenario: PR review dispatches review-only harness
Given a custom harness "review-ping" with:
"""
Expand Down
91 changes: 91 additions & 0 deletions internal/cli/dispatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -144,6 +146,95 @@ func TestRunDispatch_GHAEventMissingEventPath(t *testing.T) {
require.Error(t, err)
}

func TestRunDispatch_DisabledAgentExcluded(t *testing.T) {
Comment thread
ggallen marked this conversation as resolved.
dir := t.TempDir()
harnessDir := filepath.Join(dir, "harness")
require.NoError(t, os.MkdirAll(harnessDir, 0o755))
harnessYAML := `agent: agents/triage.md
role: triage
slug: fullsend-ai-issue-ping
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-ping"
`
require.NoError(t, os.WriteFile(filepath.Join(harnessDir, "issue-ping.yaml"), []byte(harnessYAML), 0o644))
f := false
cfg := config.NewPerRepoConfig(nil, "fullsend-ai/demo")
cfg.Agents = []config.AgentEntry{
{Name: "issue-ping", Source: "harness/issue-ping.yaml", Enabled: &f},
}
data, err := yaml.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), data, 0o644))

eventPath := filepath.Join(t.TempDir(), "event.json")
eventJSON := []byte(`{
"repo": "fullsend-ai/demo",
"entity": {"kind": "work_item", "id": 42, "url": "https://github.com/fullsend-ai/demo/issues/42"},
"transition": {"kind": "label_changed", "label": {"name": "ready-for-ping", "action": "added"}},
"actor": {"id": "alice", "kind": "human", "role": "write", "is_entity_author": false},
"state": {"labels": ["ready-for-ping"]},
"source": {"system": "github", "raw_type": "issues", "raw_action": "labeled"}
}`)
require.NoError(t, os.WriteFile(eventPath, eventJSON, 0o644))

old := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w

errCh := make(chan error, 1)
go func() {
defer w.Close()
errCh <- runDispatch(context.Background(), dispatchOpts{
inputDriver: "json",
outputDriver: "json",
inputFile: eventPath,
configDir: dir,
})
}()

var buf bytes.Buffer
_, _ = buf.ReadFrom(r)
os.Stdout = old

require.NoError(t, <-errCh)
output := strings.TrimSpace(buf.String())
if output == "" {
return
}
var refs []map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(output), &refs), "output should be valid JSON: %s", output)
for _, ref := range refs {
assert.NotEqual(t, "issue-ping", ref["agent"], "disabled agent should not appear in dispatch output")
}
}

func TestFindConfigAgentEntry_ReturnsLastEnabled(t *testing.T) {
tr := true
f := false
agents := []config.AgentEntry{
{Name: "retro", Source: "harness/retro-v1.yaml", Enabled: &tr},
{Name: "retro", Enabled: &f},
{Name: "retro", Source: "harness/retro-v2.yaml", Enabled: &tr},
}
entry := findConfigAgentEntry(agents, "retro")
require.NotNil(t, entry)
assert.Equal(t, "harness/retro-v2.yaml", entry.Source)
}

func TestFindConfigAgentEntry_SkipsDisabled(t *testing.T) {
f := false
agents := []config.AgentEntry{
{Name: "retro", Source: "harness/retro.yaml", Enabled: &f},
}
entry := findConfigAgentEntry(agents, "retro")
assert.Nil(t, entry)
}

func writeDispatchFixture(t *testing.T, dir string) {
t.Helper()
harnessDir := filepath.Join(dir, "harness")
Expand Down
11 changes: 9 additions & 2 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2933,6 +2933,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 Expand Up @@ -2965,8 +2972,8 @@ func resolveAgentSource(ctx context.Context, fullsendDir, agentName string, forg

func findConfigAgentEntry(agents []config.AgentEntry, name string) *config.AgentEntry {
lower := strings.ToLower(name)
for i := range agents {
if strings.ToLower(agents[i].DerivedName()) == lower {
for i := len(agents) - 1; i >= 0; i-- {
if strings.ToLower(agents[i].DerivedName()) == lower && agents[i].IsEnabled() {
Comment thread
ggallen marked this conversation as resolved.
return &agents[i]
}
}
Expand Down
Loading
Loading