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
2 changes: 2 additions & 0 deletions docs/ADRs/0024-harness-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ agent_input: <directory>
# another agent, and where that agent runs, is an open question.
validation_loop:
script: scripts/<validate>.sh # exit 0 = pass, non-zero = retry
schema: schemas/result.schema.json # output schema (resolved like script)
max_iterations: 3 # how many times the agent can retry
feedback_mode: append # append validation output to agent prompt

Expand Down Expand Up @@ -542,6 +543,7 @@ post_script: scripts/code-post.sh

validation_loop:
script: scripts/validate-lint.sh
schema: schemas/code-result.schema.json
max_iterations: 3
feedback_mode: append

Expand Down
6 changes: 4 additions & 2 deletions docs/ADRs/0045-forge-portable-harness-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,10 @@ skills:
- skills/issue-labels
- skills/output-schema-validation
runner_env:
FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/triage-result.schema.json
FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/triage-result.schema.json # legacy — prefer validation_loop.schema
validation_loop:
script: scripts/validate-output-schema.sh
schema: schemas/triage-result.schema.json
Comment thread
ggallen marked this conversation as resolved.
max_iterations: 2

forge:
Expand Down Expand Up @@ -295,9 +296,10 @@ skills:
- skills/issue-labels
validation_loop:
script: scripts/validate-output-schema.sh
schema: schemas/triage-result.schema.json
max_iterations: 2
runner_env:
FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/triage-result.schema.json
FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/triage-result.schema.json # legacy — prefer validation_loop.schema

forge:
github:
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/user/building-custom-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ pre_script: customized/scripts/pre-my-agent.sh

validation_loop:
script: scripts/validate-output-schema.sh
schema: customized/schemas/my-agent-result.schema.json
max_iterations: 2

post_script: customized/scripts/post-my-agent.sh
Expand All @@ -148,7 +149,6 @@ env:
MY_VAR: "${MY_VAR}"
ISSUE_KEY: "${ISSUE_KEY}"
GH_TOKEN: "${GH_TOKEN}" # auto-minted in CI when --mint-url is provided
FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/customized/schemas/my-agent-result.schema.json

timeout_minutes: 20

Expand Down
6 changes: 5 additions & 1 deletion docs/guides/user/customizing-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ post_script: scripts/post-code.sh

validation_loop:
script: scripts/validate-output-schema.sh
schema: schemas/result.schema.json
max_iterations: 2

env:
Expand All @@ -53,7 +54,9 @@ env:
providers: # Inference providers (loaded from providers/ dir)
- vertex # References providers/vertex.yaml

validation_loop:
validation_loop: # script is required; these sub-fields are optional
script: scripts/validate-output-schema.sh
schema: schemas/result.schema.json # JSON Schema file for output validation (optional)
Comment thread
ggallen marked this conversation as resolved.
feedback_mode: stderr # "stderr", "stdout", or "exit_code" (optional)

allowed_remote_resources: # URL prefixes allowed for remote skills/agents/policies
Expand Down Expand Up @@ -357,6 +360,7 @@ post_script: scripts/post-code.sh

validation_loop:
script: scripts/custom-validate.sh # Changed script
schema: schemas/custom-result.schema.json
max_iterations: 5 # Changed from: 2

env:
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,12 @@ func resolveFromLock(h *harness.Harness, entry *lock.HarnessLock, workspaceRoot
// Same as forge pre_script above.
case strings.HasPrefix(m.field, "forge.") && strings.HasSuffix(m.field, ".validation_loop.script"):
// Same as forge pre_script above.
case m.field == "validation_loop.schema":
if h.ValidationLoop != nil {
h.ValidationLoop.Schema = m.localPath
}
case strings.HasPrefix(m.field, "forge.") && strings.HasSuffix(m.field, ".validation_loop.schema"):
// Same as forge pre_script above.
default:
var idx int
if _, err := fmt.Sscanf(m.field, "skills[%d]", &idx); err == nil && idx >= 0 && idx < len(h.Skills) {
Expand Down
35 changes: 35 additions & 0 deletions internal/cli/lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,41 @@ func TestResolveFromLock_BaseFieldNoOp(t *testing.T) {
assert.True(t, baseDep.CacheHit)
}

func TestResolveFromLock_ValidationLoopSchema(t *testing.T) {
agentContent := []byte("You are a coding agent.")
agentHash := fetch.ComputeSHA256(agentContent)
schemaContent := []byte(`{"type":"object"}`)
schemaHash := fetch.ComputeSHA256(schemaContent)

root := t.TempDir()
require.NoError(t, fetch.CachePut(root, "https://example.com/agents/code.md", agentContent))
require.NoError(t, fetch.CachePut(root, "https://example.com/schemas/result.json", schemaContent))

entry := &lock.HarnessLock{
Dependencies: []lock.DependencyEntry{
{Field: "agent", URL: "https://example.com/agents/code.md", SHA256: agentHash},
{Field: "validation_loop.schema", URL: "https://example.com/schemas/result.json", SHA256: schemaHash},
},
}

h := &harness.Harness{
Agent: "https://example.com/agents/code.md#sha256=" + agentHash,
ValidationLoop: &harness.ValidationLoop{
Script: "scripts/validate.sh",
Schema: "https://example.com/schemas/result.json#sha256=" + schemaHash,
},
}

printer := ui.New(os.Stdout)
deps, err := resolveFromLock(h, entry, root, printer)
require.NoError(t, err)
require.Len(t, deps, 2)

assert.Equal(t, "validation_loop.schema", deps[1].Field)
assert.True(t, deps[1].CacheHit)
assert.True(t, strings.HasSuffix(h.ValidationLoop.Schema, "/content"))
}

func TestRunLock_URLBaseOnlyDeps(t *testing.T) {
// A child harness with a URL base and no other URL references.
// The baseDeps conversion loop runs and the base-only-deps path is taken
Expand Down
16 changes: 15 additions & 1 deletion internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,12 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
}
}

// Expand ${VAR} references in validation_loop.schema so the path
// resolves before ValidateFilesExist stat-checks it.
if h.ValidationLoop != nil && strings.Contains(h.ValidationLoop.Schema, "${") {
h.ValidationLoop.Schema = os.Expand(h.ValidationLoop.Schema, expander)
Comment thread
ggallen marked this conversation as resolved.
}

if err := h.ValidateFilesExist(); err != nil {
printer.StepFail("File validation failed")
return fmt.Errorf("validating files: %w", err)
Expand Down Expand Up @@ -1406,8 +1412,16 @@ func bootstrapEnv(sandboxName, remoteRepositoryDir string, h *harness.Harness, r

// Expose output schema and expected filename inside the sandbox so
// agents can self-check output with fullsend-check-output. See #1107.
// Prefer validation_loop.schema (already resolved by compose); fall
// back to the legacy RunnerEnv path for backward compatibility.
remoteSchemaPath := sandbox.SandboxWorkspace + "/.fullsend/output-schema.json"
if schemaHost, ok := h.RunnerEnv["FULLSEND_OUTPUT_SCHEMA"]; ok && schemaHost != "" {
var schemaHost string
if h.ValidationLoop != nil && h.ValidationLoop.Schema != "" {
schemaHost = h.ValidationLoop.Schema
} else if v, ok := h.RunnerEnv["FULLSEND_OUTPUT_SCHEMA"]; ok && v != "" {
schemaHost = v
}
if schemaHost != "" {
if _, statErr := os.Stat(schemaHost); statErr != nil {
fmt.Fprintf(os.Stderr, "WARNING: schema file not found on host: %s\n", schemaHost)
} else {
Expand Down
66 changes: 66 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1628,6 +1628,72 @@ func TestBootstrapEnv_SkipsFetchVarsWhenEmpty(t *testing.T) {
assert.Contains(t, err.Error(), "copying .env file to sandbox")
}

func TestBootstrapEnv_ValidationLoopSchemaPrecedence(t *testing.T) {
dir := t.TempDir()
schemaFile := filepath.Join(dir, "schema.json")
require.NoError(t, os.WriteFile(schemaFile, []byte(`{"type":"object"}`), 0o644))

h := &harness.Harness{
Agent: "agents/test.md",
ValidationLoop: &harness.ValidationLoop{
Script: "scripts/validate.sh",
Schema: schemaFile,
},
RunnerEnv: map[string]string{
"FULLSEND_OUTPUT_SCHEMA": "/should/not/be/used",
},
}

err := bootstrapEnv("nonexistent-sandbox", "/workspace/repo", h, nil)

// Expected to fail at sandbox operations — the schema code path is
// exercised before the failure.
require.Error(t, err)
}

func TestBootstrapEnv_ValidationLoopSchemaFallback(t *testing.T) {
h := &harness.Harness{
Agent: "agents/test.md",
RunnerEnv: map[string]string{
"FULLSEND_OUTPUT_SCHEMA": "/nonexistent/schema.json",
},
}

err := bootstrapEnv("nonexistent-sandbox", "/workspace/repo", h, nil)

require.Error(t, err)
assert.Contains(t, err.Error(), "copying .env file to sandbox")
}

func TestExpandValidationLoopSchema(t *testing.T) {
dir := t.TempDir()
schemaDir := filepath.Join(dir, "schemas")
require.NoError(t, os.MkdirAll(schemaDir, 0o755))
schemaPath := filepath.Join(schemaDir, "result.json")
require.NoError(t, os.WriteFile(schemaPath, []byte(`{"type":"object"}`), 0o644))

expander := func(key string) string {
if key == "FULLSEND_DIR" {
return dir
}
return ""
}

h := &harness.Harness{
ValidationLoop: &harness.ValidationLoop{
Schema: "${FULLSEND_DIR}/schemas/result.json",
},
}

if h.ValidationLoop != nil && strings.Contains(h.ValidationLoop.Schema, "${") {
h.ValidationLoop.Schema = os.Expand(h.ValidationLoop.Schema, expander)
}

assert.Equal(t, schemaPath, h.ValidationLoop.Schema)
_, err := os.Stat(h.ValidationLoop.Schema)
require.NoError(t, err, "expanded schema path should exist")
}

func TestBuildSandboxEnvLines_FromEnvSandbox(t *testing.T) {
h := &harness.Harness{
Agent: "agents/test.md",
Expand Down
23 changes: 23 additions & 0 deletions internal/harness/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,17 @@ func resolveBaseScripts(ctx context.Context, base *Harness, baseURL string, allo
base.ValidationLoop.Script = cachePath
deps = append(deps, dep)
}
if base.ValidationLoop != nil && base.ValidationLoop.Schema != "" {
if err := validateBaseRelPath("validation_loop.schema", base.ValidationLoop.Schema); err != nil {
return nil, err
}
dep, cachePath, err := fetchBaseFile(ctx, "validation_loop.schema", baseURLDir, base.ValidationLoop.Schema, allowlist, opts, "resource", false)
if err != nil {
return nil, err
}
base.ValidationLoop.Schema = cachePath
deps = append(deps, dep)
}

for platform, fc := range base.Forge {
if fc == nil {
Expand Down Expand Up @@ -638,6 +649,18 @@ func resolveBaseScripts(ctx context.Context, base *Harness, baseURL string, allo
fc.ValidationLoop.Script = cachePath
deps = append(deps, dep)
}
if fc.ValidationLoop != nil && fc.ValidationLoop.Schema != "" {
fieldName := fmt.Sprintf("forge.%s.validation_loop.schema", platform)
if err := validateBaseRelPath(fieldName, fc.ValidationLoop.Schema); err != nil {
return nil, err
}
dep, cachePath, err := fetchBaseFile(ctx, fieldName, baseURLDir, fc.ValidationLoop.Schema, allowlist, opts, "resource", false)
if err != nil {
return nil, err
}
fc.ValidationLoop.Schema = cachePath
deps = append(deps, dep)
}
}

// agent_input is a directory at runtime (uploaded recursively) and cannot
Expand Down
Loading
Loading