diff --git a/agent/nanobot/foreach.go b/agent/nanobot/foreach.go new file mode 100644 index 0000000..8104937 --- /dev/null +++ b/agent/nanobot/foreach.go @@ -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 + } +} diff --git a/agent/nanobot/foreach_test.go b/agent/nanobot/foreach_test.go new file mode 100644 index 0000000..e686a2c --- /dev/null +++ b/agent/nanobot/foreach_test.go @@ -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"), + ) + }) +} diff --git a/agent/nanobot/hoist.go b/agent/nanobot/hoist.go new file mode 100644 index 0000000..9d207d9 --- /dev/null +++ b/agent/nanobot/hoist.go @@ -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() +} diff --git a/agent/nanobot/hoist_test.go b/agent/nanobot/hoist_test.go new file mode 100644 index 0000000..3274e54 --- /dev/null +++ b/agent/nanobot/hoist_test.go @@ -0,0 +1,121 @@ +// +// 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" +) + +// ============================================================================= +// TestHoist +// ============================================================================= + +func TestHoist(t *testing.T) { + t.Run("BiMapHoistSuccess", func(t *testing.T) { + // Define source and target types with matching field types + type Source struct { + FieldA int + FieldB string + } + type Target struct { + FieldA int + FieldB string + } + + // Create a hoister using BiMap + hoister := nanobot.BiMap[Source, int, Target, string]() + + // Define an Arr[Target] that modifies the target + arrTarget := func(ctx context.Context, target Target, opt ...chatter.Opt) (Target, error) { + target.FieldA += 10 + target.FieldB += "_modified" + return target, nil + } + + // Hoist the Arr[Target] to Arr[Source] + arrSource := hoister.Hoist(arrTarget) + + // Test the hoisted function + source := Source{FieldA: 5, FieldB: "hello"} + result, err := arrSource(context.Background(), source) + + it.Then(t).Should( + it.Nil(err), + it.Equal(result.FieldA, 5), // A field unchanged + it.Equal(result.FieldB, "_modified"), + ) + }) + + t.Run("BiMapHoistErrorPropagation", func(t *testing.T) { + type Source struct { + FieldA int + FieldB string + } + type Target struct { + FieldA int + FieldB string + } + + hoister := nanobot.BiMap[Source, int, Target, string]() + + // Arr[Target] that returns an error + testErr := errors.New("test error") + arrTarget := func(ctx context.Context, target Target, opt ...chatter.Opt) (Target, error) { + return target, testErr + } + + arrSource := hoister.Hoist(arrTarget) + + source := Source{FieldA: 1, FieldB: "test"} + result, err := arrSource(context.Background(), source) + + it.Then(t).Should( + it.Equal(err, testErr), + it.Equal(result.FieldA, 1), // unchanged + it.Equal(result.FieldB, "test"), // unchanged + ) + }) + + t.Run("BiMapHoistWithDifferentTypes", func(t *testing.T) { + // Test with different field types but same structure + type Source struct { + A float64 + B bool + } + type Target struct { + A float64 + B bool + } + + hoister := nanobot.BiMap[Source, float64, Target, bool]() + + arrTarget := func(ctx context.Context, target Target, opt ...chatter.Opt) (Target, error) { + target.A *= 2 + target.B = !target.B + return target, nil + } + + arrSource := hoister.Hoist(arrTarget) + + source := Source{A: 3.5, B: true} + result, err := arrSource(context.Background(), source) + + it.Then(t).Should( + it.Nil(err), + it.Equal(result.A, 3.5), // A field unchanged + it.Equal(result.B, true), // !false + ) + }) +} diff --git a/doc/Kleisli-Cat-AI-Agent-Behavior.md b/doc/Kleisli-Cat-AI-Agent-Behavior.md index 0a8c82a..674638a 100644 --- a/doc/Kleisli-Cat-AI-Agent-Behavior.md +++ b/doc/Kleisli-Cat-AI-Agent-Behavior.md @@ -12,8 +12,8 @@ categorical account of that intuition. We show that every agent behaviour — sequential composition, reflective self-correction, and plan-then-execute — can be expressed as a morphism in the **Kleisli category** of the combined error-and-state monad. The resulting algebra has two primitive morphisms ($\text{ReAct}$ and -$\text{Pure}$), three bridging concepts ($\text{Arr}$, $\text{Arrow}$, -$\text{Eff}$), and three structural combinators ($\text{Seq}$, +$\text{Pure}$), four bridging concepts ($\text{Arr}$, $\text{Arrow}$, +$\text{Eff}$, $\text{Hoist}$), and three structural combinators ($\text{Seq}$, $\text{Reflect}$, $\text{ThinkReAct}$) that correspond directly to well-known patterns in multi-agent system design. @@ -331,6 +331,75 @@ By left identity ($\eta(x) \mathbin{\gg\!=} k = k(x)$): $$= b(s) \mathbin{\gg\!=} \lambda a.\; \eta(f(s,\, g(s, a))) = \text{Map}(b,\; f \bullet g)(s) \quad\square$$ +### 3.8 Cross-Type Lifting: $\text{Hoist}$ + + + +$\text{Arrow}$ bridges $\text{Bot}[S, A]$ and $\text{Arr}[S]$ within a +single blackboard type. A complementary problem arises when an existing +$\text{Arr}[T]$ pipeline — built for a different blackboard $T$ — must be +embedded into $\text{Arr}[S]$. This is the *cross-type lifting* problem: +how to reuse a finished sub-pipeline without duplicating it. + +The solution is a *partial isomorphism* between $S$ and $T$, defined by +four lenses that share exactly two fields — one for input, one for output: + +$$\text{BiMap}[S, A, T, B] \;\triangleq\; (\ell_{SA},\, \ell_{TA},\, \ell_{TB},\, \ell_{SB})$$ + + + +$\text{Hoist}$ uses this isomorphism to lift $\text{Arr}[T]$ into +$\text{Arr}[S]$: + +$$\text{Hoist} : \text{BiMap}[S, A, T, B] \times \text{Arr}[T] \to \text{Arr}[S]$$ + +$$\text{Hoist}(\text{iso}, f)(s) \;\triangleq\; f\!\bigl(\ell_{TA}.\text{put}(\mathbf{0}_T,\; \ell_{SA}.\text{get}(s))\bigr) \mathbin{\gg\!=} \lambda t'.\; \eta\!\bigl(\ell_{SB}.\text{put}(s,\; \ell_{TB}.\text{get}(t'))\bigr)$$ + +where $\mathbf{0}_T$ is the zero-value allocation of type $T$. The three +steps are: +1. **Inject** (pure): allocate $\mathbf{0}_T$ and copy $S$'s field $A$ + into it via $\ell_{TA}$. The rest of $T$ is zero-initialised. +2. **Run** (effectful): apply the inner arrow $f : \text{Arr}[T]$ to the + constructed $T$, which may perform LLM calls, tool invocations, or any + composed sub-pipeline. +3. **Project** (pure): read field $B$ from the resulting $t'$ via + $\ell_{TB}$ and write it back into the original $s$ via $\ell_{SB}$. + All other fields of $S$ are preserved. + +**Lemma 3.9** (Hoist Well-Definedness). *For any +$\text{iso} : \text{BiMap}[S, A, T, B]$ and $f : \text{Arr}[T]$, +$\text{Hoist}(\text{iso}, f) : \text{Arr}[S]$.* + +*Proof.* $f : T \to M\,T$. The inject step produces a value of type $T$ +purely. Then $f$ applied to it yields $M\,T$. In the success branch, +$\ell_{TB}.\text{get}(t') : B$ and +$\ell_{SB}.\text{put}(s, \cdot) : B \to S$, so +$\ell_{SB}.\text{put}(s, \ell_{TB}.\text{get}(t')) : S$. +Wrapping with $\eta$ yields $M\,S$. The overall composition has type +$S \to M\,S = \text{Arr}[S]$. $\square$ + +**Lemma 3.10** (Hoist Preserves $S$). *All fields of $s : S$ except the +$B$-component are unchanged by $\text{Hoist}(\text{iso}, f)(s)$.* + +*Proof.* The project step applies only $\ell_{SB}.\text{put}$, which +modifies exactly the $B$-field of $s$ by the lens put-put law. Every other +component of $s$ is carried unchanged from the inject step. $\square$ + +$\text{Hoist}$ is the mechanism for reusing sub-pipelines across +differently-shaped blackboards without code duplication. Because its result +type is exactly $\text{Arr}[S]$, it composes freely with $\text{Seq}$, +$\text{Reflect}$, and $\text{ThinkReAct}$. + + ## 4. Compositional Patterns ### 4.1 Sequential Composition: $\text{Seq}$ @@ -585,6 +654,7 @@ $$\begin{array}{ll} \quad\quad\quad\;\vdash\;\gamma : S \times [T] \to S & \text{— gather fold} \\ \\ \text{Lift}(e) & \text{— embed } \text{Eval}[S] \hookrightarrow \text{Arr}[S] \\ +\text{Hoist}(\text{iso}, f) & \text{— lift Arr}[T] \to \text{Arr}[S] \text{ via BiMap}[S,A,T,B] \\ \text{Judge}(b, \varphi) & \text{— lift Bot}[S, A] \to \text{Bot}[S, \text{Vote}[S]] \\ \text{Think}(b, \sigma) & \text{— transform Bot}[S, [A]] \to \text{Bot}[S, [T]] \\ \text{Map}(b, f) & \text{— functor on output: Bot}[S, A] \to \text{Bot}[S, B] @@ -731,7 +801,27 @@ $\varphi : \text{Eff}[\text{Vote}[S], A]$ encodes the decision policy separately, promoting reuse: the same underlying evaluator bot can serve different reflection policies by varying only $\varphi$. -### 8.5 $\text{ReAct}$ and $\text{Pure}$: Two Modes of Bot Construction +### 8.5 $\text{Hoist}$: Cross-Type Pipeline Reuse + +$\text{Arrow}$ and $\text{Lift}$ both embed computations *into* $\text{Arr}[S]$ +from below — from $\text{Bot}[S, A]$ or from $\text{Eval}[S]$. $\text{Hoist}$ +embeds from the *side*: it takes a finished $\text{Arr}[T]$ pipeline and +integrates it into $\text{Arr}[S]$ without any knowledge of $T$'s internal +structure beyond two lens fields. + +This is architecturally significant. A team may build a complete +$\text{Arr}[T]$ pipeline for a focused sub-task (e.g. entity extraction, +document classification) and then embed it into a larger orchestration +blackboard $S$ by providing only the input/output field mapping +$\text{BiMap}[S, A, T, B]$. The inner pipeline does not need to be rewritten; +the outer pipeline does not need to know its internal structure. + +The inject/project steps in $\text{Hoist}$ (Lemma 3.10) ensure that the +outer blackboard is *surgically* updated: only the designated output field +$B$ changes. This is the cross-type analogue of the lens-based put operation +that $\text{Arrow}$ uses within a single type. + +### 8.6 $\text{ReAct}$ and $\text{Pure}$: Two Modes of Bot Construction Every combinator in the hierarchy except $\text{ReAct}$ is a *deterministic* structural transformation: $\text{Arrow}$ applies a pure fold and a @@ -768,8 +858,12 @@ The three structural combinators — $\text{Seq}$ (monoid composition), $\text{Reflect}$ (bounded iteration with $\text{Vote}$/$\text{Judge}$), and $\text{ThinkReAct}$ (cross-category transform/unfold traversal) — are derivable instances of standard categorical constructions. $\text{Lift}$, -$\text{Judge}$, $\text{Think}$, and $\text{Map}$ are derived forms that -improve ergonomics without extending the core. +$\text{Hoist}$, $\text{Judge}$, $\text{Think}$, and $\text{Map}$ are derived +forms that improve ergonomics without extending the core. In particular, +$\text{Hoist}$ (§3.8) introduces a lens-based partial isomorphism +$\text{BiMap}[S, A, T, B]$ that lifts any $\text{Arr}[T]$ into $\text{Arr}[S]$, +enabling type-safe reuse of sub-pipelines across differently-shaped blackboards +with surgical field-level coupling (Lemmas 3.9–3.10). All key properties — associativity of $\text{Seq}$ (Theorem 6.1), identity erasure (Corollary 6.2), functor laws for $\text{Map}$ (Theorem 6.4), and diff --git a/doc/Kleisli-Cat-AI-Agent-Behavior.pdf b/doc/Kleisli-Cat-AI-Agent-Behavior.pdf index 83f311e..5d02eb8 100644 Binary files a/doc/Kleisli-Cat-AI-Agent-Behavior.pdf and b/doc/Kleisli-Cat-AI-Agent-Behavior.pdf differ diff --git a/doc/system-prompt-agent-algebra.md b/doc/system-prompt-agent-algebra.md index f428dde..a3412a6 100644 --- a/doc/system-prompt-agent-algebra.md +++ b/doc/system-prompt-agent-algebra.md @@ -15,7 +15,7 @@ ReAct[A, B] — a primitive LLM agent (the only non-deterministic component) Pure(f) — a deterministic agent: lifts f : S → M A into Bot[S, A] (no LLM call) ``` -### Bridging (Bot → Arr) +### Bridging (Bot → Arr, Arr[T] → Arr[S]) ``` Lens[S, A] — pure function S × A → S that writes A into S @@ -23,6 +23,11 @@ Eval[S] — effectful post-processing S ⇝ S (persistence, validation) Eff[S, A] — Lens[S, A] × Eval[S] bundled together Arrow(bot, eff) — lifts Bot[S, A] into Arr[S] by applying Eff Lift(eval) — injects a pure Eval[S] into Arr[S] (no LLM call) + +BiMap[S, A, T, B] — constructs an isomorphism S ≅ T by sharing field A (input) + copied from S into T, and field B (output) written back into S +Hoist(iso, arrT) — lifts Arr[T] into Arr[S] via iso: copies S.A → T.A, + runs arrT, copies T.B → S.B; rest of S is untouched ``` ### Structural Combinators @@ -95,6 +100,7 @@ List any custom Lens, Eval, scatter (σ), or gather (γ) functions with their lo 8. **Nest freely.** Reflect inside Seq, ThinkReAct inside Reflect, ThinkReAct inside ThinkReAct — the algebra is closed. 9. **Name the blackboard fields.** Every intermediate result has a home in S. If you can't name where a result goes, the Lens is missing. 10. **Minimize state.** Only put in S what downstream steps actually read. Transient per-task data belongs in T, not S. +11. **Hoist for cross-state reuse.** When an existing `Arr[T]` pipeline can operate on a subset of `S`, use `Hoist(BiMap[S,A,T,B], arrT)` to embed it into `Arr[S]`. `BiMap` bridges the two blackboard types by copying the shared input field `A` from `S` into a fresh `T`, running the inner arrow, then writing the result field `B` back into `S`. The remainder of `S` is untouched. Use this to reuse sub-pipelines across differently-shaped blackboards without duplicating logic. ## Output Format