Skip to content
Closed
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
4 changes: 2 additions & 2 deletions go/internal/dag/gates.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func executeSingleIssue(
if result.Outcome == schemas.IssueOutcomeCompleted || result.Outcome == schemas.IssueOutcomeCompletedWithDebt {
result.Adaptations = adaptations
result.DebtItems = debtItems
result.FinalAcceptanceCriteria = asStringSlice(currentIssue["acceptance_criteria"])
result.FinalAcceptanceCriteria = schemas.StrList(asStringSlice(currentIssue["acceptance_criteria"]))
return result, nil
}

Expand Down Expand Up @@ -218,7 +218,7 @@ func executeSingleIssue(
AdvisorInvocations: advisorRound + 1,
Adaptations: adaptations,
DebtItems: debtItems,
FinalAcceptanceCriteria: asStringSlice(currentIssue["acceptance_criteria"]),
FinalAcceptanceCriteria: schemas.StrList(asStringSlice(currentIssue["acceptance_criteria"])),
IterationHistory: result.IterationHistory,
}, nil

Expand Down
8 changes: 8 additions & 0 deletions go/internal/dag/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

"github.com/Agent-Field/SWE-AF/go/internal/coding"
Expand Down Expand Up @@ -104,6 +105,13 @@ func asStringSlice(v any) []string {
out = append(out, asStr(e))
}
return out
case string:
// LLM shape tolerance (ports ensure_str_list): a bare string where a
// list is expected becomes a one-element list instead of vanishing.
if strings.TrimSpace(t) == "" {
return nil
}
return []string{t}
default:
return nil
}
Expand Down
45 changes: 44 additions & 1 deletion go/internal/dagutil/dagutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/Agent-Field/SWE-AF/go/internal/schemas"
)
Expand Down Expand Up @@ -52,11 +53,51 @@ func asStringSlice(v any) []string {
out = append(out, asString(e))
}
return out
case string:
// LLM shape tolerance (ports ensure_str_list): a bare string where a
// list is expected becomes a one-element list instead of vanishing.
if strings.TrimSpace(t) == "" {
return nil
}
return []string{t}
default:
return nil
}
}

// issueListFields are the issue-dict fields that must be list[str]. Ports
// dag_utils._ISSUE_LIST_FIELDS.
var issueListFields = []string{
"acceptance_criteria", "depends_on", "provides",
"files_to_create", "files_to_modify",
}

// NormalizeIssueDict coerces LLM-emitted scalar shapes on a raw issue dict, in
// place. Ports dag_utils.normalize_issue_dict: raw issue dicts bypass schema
// validation (DAGState.AllIssues is []map[string]any), so a bare-string
// acceptance criterion survives until something re-validates the state — a
// checkpoint reload or the replanner's DAGState — and kills the build long
// after the cheap moment to catch it. Normalize at ingestion.
func NormalizeIssueDict(issue map[string]any) map[string]any {
for _, field := range issueListFields {
v, ok := issue[field]
if !ok {
continue
}
switch t := v.(type) {
case string:
if strings.TrimSpace(t) == "" {
issue[field] = []string{}
} else {
issue[field] = []string{t}
}
case nil:
issue[field] = []string{}
}
}
return issue
}

// asInt coerces a value to an int, treating absent/None/non-numeric as 0.
// JSON numbers arrive as float64; ints/int64 are also accepted. This mirrors
// Python's `x or 0` truthiness where 0/None both become 0.
Expand Down Expand Up @@ -437,7 +478,8 @@ func ApplyReplan(state *schemas.DAGState, decision schemas.ReplanDecision) (*sch
for _, updated := range decision.UpdatedIssues {
name := asString(updated["name"])
if existing, ok := remainingByName[name]; ok {
for k, v := range updated {
normalized := NormalizeIssueDict(copyMap(updated))
for k, v := range normalized {
existing[k] = v
}
}
Expand All @@ -459,6 +501,7 @@ func ApplyReplan(state *schemas.DAGState, decision schemas.ReplanDecision) (*sch
}
}
for _, newIssue := range decision.NewIssues {
newIssue = NormalizeIssueDict(copyMap(newIssue))
name := asString(newIssue["name"])
if name != "" {
if _, exists := remainingByName[name]; !exists {
Expand Down
88 changes: 88 additions & 0 deletions go/internal/dagutil/normalize_llm_shapes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package dagutil

// Regression tests for LLM-emitted scalar shapes on issue data — the Go
// mirror of tests/test_llm_shape_normalization.py. See that file's docstring
// for the incident this guards against.

import (
"testing"

"github.com/Agent-Field/SWE-AF/go/internal/schemas"
)

func TestNormalizeIssueDictCoercesScalars(t *testing.T) {
issue := map[string]any{
"name": "fix-1",
"acceptance_criteria": "AC-1: single string",
"depends_on": "other-issue",
"provides": nil,
"files_to_create": "a.py",
"files_to_modify": []any{"b.py"},
}
NormalizeIssueDict(issue)

if got, _ := issue["acceptance_criteria"].([]string); len(got) != 1 || got[0] != "AC-1: single string" {
t.Errorf("acceptance_criteria = %v", issue["acceptance_criteria"])
}
if got, _ := issue["depends_on"].([]string); len(got) != 1 || got[0] != "other-issue" {
t.Errorf("depends_on = %v", issue["depends_on"])
}
if got, _ := issue["provides"].([]string); got == nil || len(got) != 0 {
t.Errorf("provides = %v", issue["provides"])
}
if got, _ := issue["files_to_create"].([]string); len(got) != 1 || got[0] != "a.py" {
t.Errorf("files_to_create = %v", issue["files_to_create"])
}
// Already-list values keep their shape (untouched []any is fine).
if _, ok := issue["files_to_modify"].([]any); !ok {
t.Errorf("files_to_modify should be untouched, got %T", issue["files_to_modify"])
}
}

func TestNormalizeIssueDictLeavesAbsentAndOddTypes(t *testing.T) {
issue := map[string]any{"name": "fix-1", "acceptance_criteria": 42}
NormalizeIssueDict(issue)
if issue["acceptance_criteria"] != 42 {
t.Errorf("non-str scalar should pass through, got %v", issue["acceptance_criteria"])
}
if _, ok := issue["depends_on"]; ok {
t.Error("absent field should stay absent")
}
}

func TestApplyReplanNormalizesLLMIssueShapes(t *testing.T) {
state := &schemas.DAGState{
RepoPath: "/tmp/repo",
AllIssues: []map[string]any{
{"name": "keep", "depends_on": []any{}, "acceptance_criteria": []any{"ok"}},
},
Levels: [][]string{{"keep"}},
}
decision := schemas.ReplanDecision{
Action: schemas.ReplanActionModifyDAG,
Rationale: "r",
UpdatedIssues: []map[string]any{
{"name": "keep", "acceptance_criteria": "AC as string"},
},
NewIssues: []map[string]any{
{"name": "new-1", "depends_on": "keep", "acceptance_criteria": "single new criterion"},
},
}
state, err := ApplyReplan(state, decision)
if err != nil {
t.Fatalf("ApplyReplan: %v", err)
}
byName := map[string]map[string]any{}
for _, i := range state.AllIssues {
byName[asString(i["name"])] = i
}
if got, _ := byName["keep"]["acceptance_criteria"].([]string); len(got) != 1 || got[0] != "AC as string" {
t.Errorf("keep.acceptance_criteria = %v", byName["keep"]["acceptance_criteria"])
}
if got, _ := byName["new-1"]["acceptance_criteria"].([]string); len(got) != 1 || got[0] != "single new criterion" {
t.Errorf("new-1.acceptance_criteria = %v", byName["new-1"]["acceptance_criteria"])
}
if got, _ := byName["new-1"]["depends_on"].([]string); len(got) != 1 || got[0] != "keep" {
t.Errorf("new-1.depends_on = %v", byName["new-1"]["depends_on"])
}
}
6 changes: 6 additions & 0 deletions go/internal/roles/advisor/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"

"github.com/Agent-Field/SWE-AF/go/internal/config"
"github.com/Agent-Field/SWE-AF/go/internal/dagutil"
"github.com/Agent-Field/SWE-AF/go/internal/harnessx"
"github.com/Agent-Field/SWE-AF/go/internal/prompts/advisor"
"github.com/Agent-Field/SWE-AF/go/internal/prompts/coding"
Expand Down Expand Up @@ -186,6 +187,11 @@ func GenerateFixIssues(ctx context.Context, deps *Deps, input map[string]any) (a
deps.note(ctx, fmt.Sprintf(
"Fix generator complete: %d fix issues, %d debt items", len(parsed.FixIssues), len(parsed.DebtItems)),
"fix_generator", "complete")
// fix_issues are raw dicts headed for DAGState.all_issues — coerce
// LLM scalar shapes (str acceptance_criteria etc.) at the boundary.
for i, fi := range parsed.FixIssues {
parsed.FixIssues[i] = dagutil.NormalizeIssueDict(fi)
}
return parsed, nil
}

Expand Down
51 changes: 40 additions & 11 deletions go/internal/schemas/execution.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
package schemas

import "encoding/json"
import (
"encoding/json"
"strings"
)

// This file ports the data/result models (not the config models — those live
// in internal/config) from execution/schemas.py.

// StrList is a []string that also accepts a bare JSON string, coercing it to a
// one-element list (blank → empty). Ports the ensure_str_list before-validator
// in execution/schemas.py: weaker models sometimes emit a single criterion as
// a string where the schema wants a list, and pre-fix checkpoints may have
// persisted that shape — decoding must tolerate both.
type StrList []string

// UnmarshalJSON accepts either a JSON array of strings or a bare string.
func (s *StrList) UnmarshalJSON(b []byte) error {
var one string
if err := json.Unmarshal(b, &one); err == nil {
if strings.TrimSpace(one) == "" {
*s = StrList{}
} else {
*s = StrList{one}
}
return nil
}
var many []string
if err := json.Unmarshal(b, &many); err != nil {
return err
}
*s = StrList(many)
return nil
}

// ---------------------------------------------------------------------------
// Multi-repo models
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -89,15 +118,15 @@ type IssueAdaptation struct {

// SplitIssueSpec is a sub-issue spec when the advisor decides to SPLIT.
type SplitIssueSpec struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptance_criteria"`
DependsOn []string `json:"depends_on"`
Provides []string `json:"provides"`
FilesToCreate []string `json:"files_to_create"`
FilesToModify []string `json:"files_to_modify"`
ParentIssueName string `json:"parent_issue_name"`
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria StrList `json:"acceptance_criteria"`
DependsOn StrList `json:"depends_on"`
Provides StrList `json:"provides"`
FilesToCreate StrList `json:"files_to_create"`
FilesToModify StrList `json:"files_to_modify"`
ParentIssueName string `json:"parent_issue_name"`
}

// IssueAdvisorDecision is the structured output from the Issue Advisor agent.
Expand Down Expand Up @@ -154,7 +183,7 @@ type IssueResult struct {
DebtItems []map[string]any `json:"debt_items"`
SplitRequest *[]SplitIssueSpec `json:"split_request"`
EscalationContext string `json:"escalation_context"`
FinalAcceptanceCriteria []string `json:"final_acceptance_criteria"`
FinalAcceptanceCriteria StrList `json:"final_acceptance_criteria"`
IterationHistory []map[string]any `json:"iteration_history"`
}

Expand Down
48 changes: 48 additions & 0 deletions go/internal/schemas/strlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package schemas

import (
"encoding/json"
"testing"
)

func TestStrListAcceptsBareString(t *testing.T) {
var result IssueResult
// The incident shape: a pre-fix checkpoint serialized a bare-string
// final_acceptance_criteria. Decoding must tolerate it.
data := `{"issue_name":"fix-ac1","outcome":"completed","final_acceptance_criteria":"AC-1: exits 0"}`
if err := json.Unmarshal([]byte(data), &result); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(result.FinalAcceptanceCriteria) != 1 || result.FinalAcceptanceCriteria[0] != "AC-1: exits 0" {
t.Errorf("FinalAcceptanceCriteria = %v", result.FinalAcceptanceCriteria)
}
}

func TestStrListAcceptsListAndBlank(t *testing.T) {
var s StrList
if err := json.Unmarshal([]byte(`["a","b"]`), &s); err != nil || len(s) != 2 {
t.Fatalf("list decode: %v %v", s, err)
}
if err := json.Unmarshal([]byte(`" "`), &s); err != nil || len(s) != 0 {
t.Fatalf("blank decode: %v %v", s, err)
}
// Marshals as a plain array (checkpoint format unchanged).
out, err := json.Marshal(StrList{"x"})
if err != nil || string(out) != `["x"]` {
t.Fatalf("marshal: %s %v", out, err)
}
}

func TestSplitIssueSpecToleratesBareStrings(t *testing.T) {
var spec SplitIssueSpec
data := `{"name":"s","title":"t","description":"d","acceptance_criteria":"only criterion","files_to_modify":"one.py"}`
if err := json.Unmarshal([]byte(data), &spec); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(spec.AcceptanceCriteria) != 1 || spec.AcceptanceCriteria[0] != "only criterion" {
t.Errorf("AcceptanceCriteria = %v", spec.AcceptanceCriteria)
}
if len(spec.FilesToModify) != 1 || spec.FilesToModify[0] != "one.py" {
t.Errorf("FilesToModify = %v", spec.FilesToModify)
}
}
12 changes: 10 additions & 2 deletions swe_af/execution/dag_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ReplanAction,
ReplanDecision,
WorkspaceManifest,
ensure_str_list,
)

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -827,7 +828,12 @@ async def _execute_single_issue(
if result.outcome in (IssueOutcome.COMPLETED, IssueOutcome.COMPLETED_WITH_DEBT):
result.adaptations = adaptations
result.debt_items = debt_items
result.final_acceptance_criteria = current_issue.get("acceptance_criteria", [])
# ensure_str_list: attribute assignment bypasses Pydantic, so a
# bare-string criterion from an LLM-sourced issue would poison the
# checkpoint here and only explode on the next DAGState validation.
result.final_acceptance_criteria = ensure_str_list(
current_issue.get("acceptance_criteria", [])
)
return result

# Advisor budget exhausted or disabled — return raw failure
Expand Down Expand Up @@ -969,7 +975,9 @@ async def _execute_single_issue(
advisor_invocations=advisor_round + 1,
adaptations=adaptations,
debt_items=debt_items,
final_acceptance_criteria=current_issue.get("acceptance_criteria", []),
final_acceptance_criteria=ensure_str_list(
current_issue.get("acceptance_criteria", [])
),
iteration_history=result.iteration_history,
)

Expand Down
Loading
Loading