Skip to content

Replace Feedback-as-Error with proper Result type #40

@fogfish

Description

@fogfish

Problem: The Feedback() function returns an error type, but feedback is not an error - it's a normal part of the agent refinement loop. This violates Go error semantics where errors represent exceptional failures, not expected control flow.

Current Implementation (codec.go):

// Feedback creates a feedback message for the LLM, packaged as an error type
func Feedback(note string, text ...string) error {
    return chatter.Feedback{Note: note, Text: text}
}

Problems with Current Approach:

  1. Violates Go conventions: Errors should represent failures, not expected feedback loops
  2. Confusing API: Users must use errors.As() to distinguish feedback from real errors
  3. Error handling ambiguity: if err != nil doesn't mean failure - it might mean "refinement needed"
  4. Leaks implementation detail: The decoder-agent feedback mechanism leaks into error handling

Design Decision: Replace with an explicit Result type that clearly separates success, feedback, and error states. This makes the API intention-clear and follows Go idioms.

Required Changes:

  1. Define Result type in codec.go:
// DecodeResult represents the outcome of decoding an LLM response.
// It uses a discriminated union pattern to clearly separate success, feedback, and error cases.
type DecodeResult[T any] struct {
    // Value contains the successfully decoded result (only valid if IsSuccess)
    Value T
    
    // Confidence is a score from 0.0 to 1.0 indicating decoder confidence in the result
    Confidence float64
    
    // Feedback contains refinement instructions for the LLM (only valid if IsFeedback)
    Feedback chatter.Content
    
    // Err contains the actual error (only valid if IsError)
    Err error
    
    state resultState
}

type resultState uint8

const (
    resultSuccess resultState = iota
    resultFeedback
    resultError
)

// Success creates a successful decode result
func Success[T any](confidence float64, value T) DecodeResult[T] {
    return DecodeResult[T]{
        Value:      value,
        Confidence: confidence,
        state:      resultSuccess,
    }
}

// Feedback creates a feedback result (not an error!)
func FeedbackResult[T any](note string, text ...string) DecodeResult[T] {
    var content chatter.Prompt
    content.With(chatter.Content{Note: note, Text: text})
    return DecodeResult[T]{
        Feedback: &content,
        state:    resultFeedback,
    }
}

// Error creates an error result (actual failure)
func DecoderError[T any](err error) DecodeResult[T] {
    return DecodeResult[T]{
        Err:   err,
        state: resultError,
    }
}

// Query methods
func (r DecodeResult[T]) IsSuccess() bool  { return r.state == resultSuccess }
func (r DecodeResult[T]) IsFeedback() bool { return r.state == resultFeedback }
func (r DecodeResult[T]) IsError() bool    { return r.state == resultError }
  1. Update Decoder interface in codec.go:
// Decoder converts LLM's reply into structured object.
type Decoder[T any] interface {
    // Decode transforms an LLM reply into type T.
    // 
    // Returns:
    //  - Success: LLM output was successfully decoded
    //  - Feedback: LLM output needs refinement (agent will retry with feedback)
    //  - Error: Unrecoverable failure (agent will abort)
    Decode(*chatter.Reply) DecodeResult[T]
}
  1. Update FromDecoder helper in codec/from.go:
// FromDecoder creates a Decoder[B] from a function.
// The function should return DecodeResult[B] with one of:
//  - Success(confidence, value) for successful decode
//  - FeedbackResult[B](note, text...) when refinement is needed
//  - DecoderError[B](err) for actual errors
func FromDecoder[B any](f func(*chatter.Reply) DecodeResult[B]) thinker.Decoder[B] {
    return fDecoder[B](f)
}

type fDecoder[T any] func(*chatter.Reply) DecodeResult[T]

func (f fDecoder[T]) Decode(reply *chatter.Reply) DecodeResult[T] { 
    return f(reply) 
}
  1. Update DecoderID in codec/identity.go:
// Identity decoder, passes LLM reply directly as result
var DecoderID = FromDecoder(
    func(reply *chatter.Reply) DecodeResult[*chatter.Reply] {
        return Success(1.0, reply)
    },
)

// Identity decoder, passes LLM reply as string object
var DecoderString = FromDecoder(
    func(reply *chatter.Reply) DecodeResult[string] {
        return Success(1.0, reply.String())
    },
)
  1. Update Automata to use Result in agent/automata.go:
func (automata *Automata[A, B]) Prompt(ctx context.Context, input A, opt ...chatter.Opt) (B, error) {
    automata.mu.Lock()
    defer automata.mu.Unlock()
    
    var nul B
    state := thinker.State[B]{Phase: thinker.AGENT_ASK, Epoch: 0}

    // ... LLM setup code ...

    for {
        reply, err := automata.llm.Prompt(ctx, shortMemory)
        if err != nil {
            return nul, thinker.ErrLLM.With(err)
        }

        // Decode result - now returns Result type
        result := automata.decoder.Decode(reply)
        
        switch {
        case result.IsSuccess():
            state.Confidence = result.Confidence
            state.Reply = result.Value
            state.Feedback = nil
            
        case result.IsFeedback():
            state.Confidence = 0.0
            state.Feedback = result.Feedback
            // Continue to reasoner with feedback
            
        case result.IsError():
            return nul, thinker.ErrCodec.With(result.Err)
        }

        state.Epoch++
        if state.Phase != thinker.AGENT_RETRY {
            automata.memory.Commit(thinker.NewObservation(prompt, reply))
        }

        phase, request, err := automata.reasoner.Deduct(state)
        if err != nil {
            return nul, err
        }

        switch phase {
        case thinker.AGENT_RETURN:
            return state.Reply, nil
        // ... other cases
        }
    }
}
  1. Update Manifold in agent/manifold.go:
func (manifold *Manifold[A, B]) Prompt(ctx context.Context, input A, opt ...chatter.Opt) (B, error) {
    manifold.mu.Lock()
    defer manifold.mu.Unlock()
    
    var nul B
    // ... setup code ...

    for {
        reply, err := manifold.llm.Prompt(ctx, memory, opt...)
        if err != nil {
            return nul, thinker.ErrLLM.With(err)
        }

        switch reply.Stage {
        case chatter.LLM_RETURN:
            result := manifold.decoder.Decode(reply)
            
            if result.IsError() {
                return nul, thinker.ErrCodec.With(result.Err)
            }
            
            if result.IsFeedback() {
                memory = append(memory, reply, result.Feedback)
                continue
            }
            
            return result.Value, nil
            
        // ... other cases
        }
    }
}
  1. Update all examples to use new Result type:

Example (examples/02_rainbow/rainbow.go):

// OLD:
func validate(seq []string) error {
    for _, x := range seq {
        if strings.ToLower(x) == "ultraviolet" {
            return nil
        }
    }
    return thinker.Feedback(
        "Improve the response based on feedback:",
        "Missing colors from invisible spectrum",
    )
}

func decode(reply *chatter.Reply) (float64, []string, error) {
    var seq []string
    if err := json.Unmarshal(reply.Bytes(), &seq); err != nil {
        return 0.0, nil, err
    }
    if err := validate(seq); err != nil {
        return 0.1, nil, err
    }
    return 1.0, seq, nil
}

// NEW:
func validate(seq []string) bool {
    for _, x := range seq {
        if strings.ToLower(x) == "ultraviolet" {
            return true
        }
    }
    return false
}

func decode(reply *chatter.Reply) thinker.DecodeResult[[]string] {
    var seq []string
    if err := json.Unmarshal(reply.Bytes(), &seq); err != nil {
        return thinker.DecoderError[[]string](err)
    }
    
    if !validate(seq) {
        return thinker.FeedbackResult[[]string](
            "Improve the response based on feedback:",
            "Missing colors from invisible spectrum",
        )
    }
    
    return thinker.Success(1.0, seq)
}
  1. Update jsonify helper in prompt/jsonify/jsonify.go:
func (strings) Decode(reply *chatter.Reply, seq any) thinker.DecodeResult[any] {
    matches := re.FindStringSubmatch(string(reply.String()))
    if len(matches) == 0 {
        return thinker.FeedbackResult[any](
            "Improve the response based on feedback:",
            "The output does not contain valid JSON list of strings.",
            "No pattern [ \"string\", \"string\", ... ] is found in the output.",
        )
    }

    if err := json.Unmarshal([]byte(matches[0]), &seq); err != nil {
        return thinker.FeedbackResult[any](
            "Improve the response based on feedback.",
            "The output does not contain valid JSON list of strings.",
            "JSON parsing of included list of strings has failed with an error  "+err.Error(),
        )
    }

    return thinker.Success(1.0, seq)
}
  1. Update documentation in README.md and godoc:

Add section explaining Result type:

### Decoder Results

Decoders return a `DecodeResult[T]` that explicitly represents three states:

```go
// Success - LLM output decoded correctly
func decode(reply *chatter.Reply) DecodeResult[Person] {
    var person Person
    if err := json.Unmarshal(reply.Bytes(), &person); err != nil {
        return DecoderError[Person](err)  // Real error
    }
    
    if person.Age < 0 {
        return FeedbackResult[Person](
            "Improve response:",
            "Age must be positive",
        )  // Agent will retry with feedback
    }
    
    return Success(1.0, person)  // Success!
}

Benefits:

  • ✅ Clear intent: Success, Feedback, or Error
  • ✅ Type-safe: Compiler ensures you handle all cases
  • ✅ No confusion: Feedback is not an error
  • ✅ Idiomatic: Result types are common in Go for discriminated unions
  1. Add comprehensive tests in codec/result_test.go:
func TestDecodeResultSuccess(t *testing.T) {
    result := Success(0.95, "test value")
    
    it.Then(t).Should(
        it.True(result.IsSuccess()),
        it.False(result.IsFeedback()),
        it.False(result.IsError()),
        it.Equal(result.Value, "test value"),
        it.Equal(result.Confidence, 0.95),
    )
}

func TestDecodeResultFeedback(t *testing.T) {
    result := FeedbackResult[string]("Improve", "Add more details")
    
    it.Then(t).Should(
        it.False(result.IsSuccess()),
        it.True(result.IsFeedback()),
        it.False(result.IsError()),
        it.True(result.Feedback != nil),
    )
}

func TestDecodeResultError(t *testing.T) {
    err := errors.New("parse failed")
    result := DecoderError[string](err)
    
    it.Then(t).Should(
        it.False(result.IsSuccess()),
        it.False(result.IsFeedback()),
        it.True(result.IsError()),
        it.Equal(result.Err, err),
    )
}

Estimated Effort: 6 hours
Skills Required:

  • Go type design
  • Refactoring
  • Understanding of discriminated unions / sum types
  • Testing

Breaking Changes: ⚠️ YES - This is a breaking change to the Decoder interface.

Migration Guide:

// Before:
func decode(reply *chatter.Reply) (float64, Result, error) {
    if invalid {
        return 0.0, Result{}, thinker.Feedback("Fix this", "Details")
    }
    if err != nil {
        return 0.0, Result{}, err
    }
    return 1.0, result, nil
}

// After:
func decode(reply *chatter.Reply) thinker.DecodeResult[Result] {
    if invalid {
        return thinker.FeedbackResult[Result]("Fix this", "Details")
    }
    if err != nil {
        return thinker.DecoderError[Result](err)
    }
    return thinker.Success(1.0, result)
}

Rationale:

  1. Idiomatic Go: Result types clearly express intent
  2. Type safety: Compiler ensures all cases are handled
  3. Clear semantics: Feedback ≠ Error ≠ Success
  4. Better errors: Real errors remain errors, feedback is separate
  5. Maintainability: Future readers understand the flow immediately
  6. No magic: No need for errors.As() type assertions

Alternative Considered: Keep current design but document better

  • ❌ Rejected: Documentation doesn't fix the semantic violation
  • ❌ Rejected: Still requires users to understand the error-that's-not-an-error pattern

Version Strategy:

  • Introduce in v0.11.0 as breaking change
  • Mark old Feedback(string, ...string) error as deprecated
  • Provide migration guide in CHANGELOG
  • Consider keeping compatibility shim for one version if needed

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions