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
38 changes: 38 additions & 0 deletions agent/nanobot/foreach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// Copyright (C) 2025 - 2026 Dmitry Kolesnikov
//
// This file may be modified and distributed under the terms
// of the MIT license. See the LICENSE file for details.
// https://github.com/kshard/thinker
//

package nanobot

import (
"context"

"github.com/fogfish/golem/optics"
"github.com/kshard/chatter"
)

// ForEach applies an Arr[T] to each element of a sequence in the state S.
// It is a special case of ThinkReAct where the scatter function is the identity
// and the gather function is the auto-derived Lens[S, []T].
func ForEach[S, T any](arrT Arr[T]) Arr[S] {
st := optics.ForProduct1[S, []T]()

return func(ctx context.Context, s S, opt ...chatter.Opt) (S, error) {
seq := st.Get(&s)

for i := range seq {
t, err := arrT(ctx, seq[i], opt...)
if err != nil {
return s, err
}
seq[i] = t
}

st.Put(&s, seq)
return s, nil
}
}
153 changes: 153 additions & 0 deletions agent/nanobot/foreach_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// Copyright (C) 2025 - 2026 Dmitry Kolesnikov
//
// This file may be modified and distributed under the terms
// of the MIT license. See the LICENSE file for details.
// https://github.com/kshard/thinker
//

package nanobot_test

import (
"context"
"errors"
"testing"

"github.com/fogfish/it/v2"
"github.com/kshard/chatter"
"github.com/kshard/thinker/agent/nanobot"
)

// =============================================================================
// TestForEach
// =============================================================================

func TestForEach(t *testing.T) {
t.Run("ForEachSuccess", func(t *testing.T) {
// Define a state type with a slice field
type State struct {
Items []int
}

// Define an Arr[int] that increments each int
arrInt := func(ctx context.Context, value int, opt ...chatter.Opt) (int, error) {
return value + 1, nil
}

// Create ForEach Arr[State]
arrState := nanobot.ForEach[State, int](arrInt)

// Test with initial state
state := State{Items: []int{1, 2, 3}}
result, err := arrState(context.Background(), state)

it.Then(t).Should(
it.Nil(err),
it.Equal(len(result.Items), 3),
it.Equal(result.Items[0], 2), // 1 + 1
it.Equal(result.Items[1], 3), // 2 + 1
it.Equal(result.Items[2], 4), // 3 + 1
)
})

t.Run("ForEachEmptySlice", func(t *testing.T) {
type State struct {
Items []string
}

arrString := func(ctx context.Context, value string, opt ...chatter.Opt) (string, error) {
return value + "_processed", nil
}

arrState := nanobot.ForEach[State, string](arrString)

state := State{Items: []string{}}
result, err := arrState(context.Background(), state)

it.Then(t).Should(
it.Nil(err),
it.Equal(len(result.Items), 0),
)
})

t.Run("ForEachErrorPropagation", func(t *testing.T) {
type State struct {
Items []int
}

testErr := errors.New("processing error")
arrInt := func(ctx context.Context, value int, opt ...chatter.Opt) (int, error) {
if value == 2 {
return 0, testErr
}
return value * 2, nil
}

arrState := nanobot.ForEach[State, int](arrInt)

state := State{Items: []int{1, 2, 3}}
result, err := arrState(context.Background(), state)

it.Then(t).Should(
it.Equal(err, testErr),
it.Equal(result.Items[0], 2), // 1 * 2 (processed before error)
it.Equal(result.Items[1], 2), // unchanged
it.Equal(result.Items[2], 3), // unchanged
)
})

t.Run("ForEachWithStringSlice", func(t *testing.T) {
type State struct {
Messages []string
}

arrString := func(ctx context.Context, value string, opt ...chatter.Opt) (string, error) {
return "processed: " + value, nil
}

arrState := nanobot.ForEach[State, string](arrString)

state := State{Messages: []string{"hello", "world"}}
result, err := arrState(context.Background(), state)

it.Then(t).Should(
it.Nil(err),
it.Equal(len(result.Messages), 2),
it.Equal(result.Messages[0], "processed: hello"),
it.Equal(result.Messages[1], "processed: world"),
)
})

t.Run("ForEachWithStructSlice", func(t *testing.T) {
type Item struct {
Value int
Name string
}
type State struct {
Items []Item
}

arrItem := func(ctx context.Context, item Item, opt ...chatter.Opt) (Item, error) {
item.Value += 100
item.Name = "updated_" + item.Name
return item, nil
}

arrState := nanobot.ForEach[State, Item](arrItem)

state := State{Items: []Item{
{Value: 10, Name: "first"},
{Value: 20, Name: "second"},
}}
result, err := arrState(context.Background(), state)

it.Then(t).Should(
it.Nil(err),
it.Equal(len(result.Items), 2),
it.Equal(result.Items[0].Value, 110),
it.Equal(result.Items[0].Name, "updated_first"),
it.Equal(result.Items[1].Value, 120),
it.Equal(result.Items[1].Name, "updated_second"),
)
})
}
87 changes: 87 additions & 0 deletions agent/nanobot/hoist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// Copyright (C) 2025 - 2026 Dmitry Kolesnikov
//
// This file may be modified and distributed under the terms
// of the MIT license. See the LICENSE file for details.
// https://github.com/kshard/thinker
//

package nanobot

import (
"context"
"reflect"
"sync"

"github.com/fogfish/golem/optics"
"github.com/kshard/chatter"
)

// Hoist lifts an Arr[T] into Arr[S] by applying an isomorphism between S and T.
type Hoister[S, T any] interface{ Hoist(Arr[T]) Arr[S] }

// BiMap creates a partial morphism between S and T.
// It allows us to treat S and T as interchangeable for the purpose of applying Arr[T] to S.
func BiMap[S, A, T, B any]() Hoister[S, T] {
return iso[S, A, T, B]{
sa: optics.ForProduct1[S, A](),
sb: optics.ForProduct1[S, B](),
ta: optics.ForProduct1[T, A](),
tb: optics.ForProduct1[T, B](),
}
}

type iso[S, A, T, B any] struct {
sa optics.Lens[S, A]
sb optics.Lens[S, B]
ta optics.Lens[T, A]
tb optics.Lens[T, B]
}

// Hoist lifts an Arr[T] into Arr[S] using the isomorphism defined by the iso struct.
func (iso iso[S, A, T, B]) Hoist(arrT Arr[T]) Arr[S] {
return func(ctx context.Context, s S, opt ...chatter.Opt) (S, error) {
t := alloc[T]()

iso.ta.Put(&t, iso.sa.Get(&s))

t, err := arrT(ctx, t, opt...)
if err != nil {
return s, err
}

iso.sb.Put(&s, iso.tb.Get(&t))
return s, nil
}
}

var allocCache sync.Map

func alloc[A any](ctor ...func() A) A {
if len(ctor) > 0 && ctor[0] != nil {
return ctor[0]()
}

var zero A
t := reflect.TypeOf(zero)

if fn, ok := allocCache.Load(t); ok {
return fn.(func() A)()
}

var fn func() A

if t.Kind() == reflect.Ptr {
elem := t.Elem()
fn = func() A {
return reflect.New(elem).Interface().(A)
}
} else {
fn = func() A {
return reflect.New(t).Elem().Interface().(A)
}
}

allocCache.Store(t, fn)
return fn()
}
Loading
Loading