From 2e9ce151fc84f6d182c19867fe47b693b8d26004 Mon Sep 17 00:00:00 2001 From: Dmitry Kolesnikov Date: Sat, 25 Apr 2026 23:54:43 +0300 Subject: [PATCH 1/4] (fix) console output abstraction --- agent/nanobot/nanobot_test.go | 34 -------------------------- agent/nanobot/react.go | 32 +++++++++++++++---------- agent/nanobot/reflect.go | 22 +++-------------- agent/nanobot/runtime.go | 45 ++++++++++++++++------------------- agent/nanobot/thinkreact.go | 15 ------------ prompt/prompt.go | 5 ---- 6 files changed, 43 insertions(+), 110 deletions(-) diff --git a/agent/nanobot/nanobot_test.go b/agent/nanobot/nanobot_test.go index 166ebf9..1108188 100644 --- a/agent/nanobot/nanobot_test.go +++ b/agent/nanobot/nanobot_test.go @@ -103,15 +103,6 @@ func TestRuntime(t *testing.T) { it.Then(t).ShouldNot(it.Nil(rt2)) it.Then(t).ShouldNot(it.Equal(rt, rt2)) }) - - t.Run("WithStdout", func(t *testing.T) { - rt := nanobot.NewRuntime(nil, nil) - chalk := &MockChalk{} - rt2 := rt.WithStdout(chalk) - - it.Then(t).ShouldNot(it.Nil(rt2)) - it.Then(t).ShouldNot(it.Equal(rt, rt2)) - }) } // ============================================================================= @@ -561,31 +552,6 @@ func TestReflect(t *testing.T) { it.Then(t).ShouldNot(it.Nil(err)) it.Then(t).Should(it.True(errors.Is(err, errEffect))) }) - - t.Run("WithChalk", func(t *testing.T) { - chalk := &MockChalk{} - rt2 := nanobot.NewRuntime(nil, nil).WithStdout(chalk) - - rawJudge := &MockBot[Work, string]{ - fn: func(_ context.Context, w Work, _ ...chatter.Opt) (string, error) { - return w.Result, nil - }, - } - react := &MockBot[Work, string]{ - fn: func(_ context.Context, _ Work, _ ...chatter.Opt) (string, error) { - return "fix", nil - }, - } - - // no eff: always-accept on first attempt - judge := nanobot.Judge[Work, string](rawJudge) - - bot, err := nanobot.NewReflect(rt2, judge, nanobot.Arrow[Work, string](react)) - it.Then(t).Should(it.Nil(err)) - _, err = bot.Prompt(context.Background(), Work{Result: "state"}) - it.Then(t).Should(it.Nil(err)) - it.Then(t).Should(it.True(chalk.dones >= 2)) - }) } // ============================================================================= diff --git a/agent/nanobot/react.go b/agent/nanobot/react.go index 90309eb..56b6899 100644 --- a/agent/nanobot/react.go +++ b/agent/nanobot/react.go @@ -41,7 +41,7 @@ type BotReAct[A, B any] struct { registry *command.SeqRegistry prompt *prompt.Prompt t *template.Template - chalk Chalk + taskf func(A) string } // ReAct is like NewReAct but panics on error. @@ -99,10 +99,7 @@ func NewReAct[A, B any](rt *Runtime, file string) (*BotReAct[A, B], error) { runner = aio.NewJsonLogger(os.Stderr, runner) } - bot := &BotReAct[A, B]{prompt: prompt, t: t, chalk: rt.Chalk} - if len(bot.prompt.Name) == 0 { - bot.chalk = devnull{} - } + bot := &BotReAct[A, B]{prompt: prompt, t: t} bot.registry = command.NewSeqRegistry() bot.registry.Bind(registry) @@ -131,6 +128,15 @@ func (bot *BotReAct[A, B]) WithRegistry(r *command.Registry) *BotReAct[A, B] { return bot } +func (bot *BotReAct[A, B]) WithTask(name string) *BotReAct[A, B] { + return bot.WithTaskf(func(A) string { return name }) +} + +func (bot *BotReAct[A, B]) WithTaskf(fn func(A) string) *BotReAct[A, B] { + bot.taskf = fn + return bot +} + // Prompt encodes the input using the prompt template, runs the Manifold // ReAct loop until the model returns a final answer, and decodes the result // into B. Progress is reported via the Chalk sink when the prompt file @@ -140,18 +146,18 @@ func (bot *BotReAct[A, B]) Prompt(ctx context.Context, input A, opt ...chatter.O bot.memory.Reset() } - bot.chalk.Task(ctx, bot.prompt.Name) + chalk, ok := ctx.Value(chalkboard).(Chalk) + if !ok || chalk == nil || bot.taskf == nil { + return bot.manifold.Prompt(ctx, input, opt...) + } + + chalk.Task(ctx, bot.taskf(input)) val, err := bot.manifold.Prompt(ctx, input, opt...) if err != nil { - if len(bot.prompt.Name) > 0 { - bot.chalk.Fail(err) - } + chalk.Fail(err) return val, err } - - if len(bot.prompt.Name) > 0 { - bot.chalk.Done() - } + chalk.Done() return val, nil } diff --git a/agent/nanobot/reflect.go b/agent/nanobot/reflect.go index d302472..c374cfc 100644 --- a/agent/nanobot/reflect.go +++ b/agent/nanobot/reflect.go @@ -114,7 +114,6 @@ type BotReflect[S any] struct { judge Bot[S, Vote[S]] correct Arr[S] attempts int - chalk Chalk } // Reflect creates a BotReflect. Panics on error. @@ -133,7 +132,6 @@ func NewReflect[S any](rt *Runtime, judge Bot[S, Vote[S]], correct Arr[S]) (*Bot judge: judge, correct: correct, attempts: 1, - chalk: rt.Chalk, }, nil } @@ -149,43 +147,29 @@ func (bot *BotReflect[S]) WithAttempts(attempts int) *BotReflect[S] { // correct arrow and the loop retries. An error is returned on a hard reject // or when the retry budget is exhausted. func (bot *BotReflect[S]) Prompt(ctx context.Context, input S, opt ...chatter.Opt) (S, error) { - bot.chalk.Task(ctx, "Reflect (%d attempts)", bot.attempts) - s := input - for i := range bot.attempts { - bot.chalk.Task(bot.chalk.Sub(ctx), "attempt %d of %d", i+1, bot.attempts) - - v, err := bot.judge.Prompt(bot.chalk.Sub(ctx), s, opt...) + for range bot.attempts { + v, err := bot.judge.Prompt(ctx, s, opt...) if err != nil { - bot.chalk.Fail(err) return *new(S), err } switch { case v.accepted > 0: - bot.chalk.Done() - bot.chalk.Done() return v.State, nil case v.accepted < 0: - bot.chalk.Done() err := fmt.Errorf("rejected") - bot.chalk.Fail(err) return v.State, err } // neutral: v.State carries the critique — forward to corrector - s, err = bot.correct(bot.chalk.Sub(ctx), v.State, opt...) + s, err = bot.correct(ctx, v.State, opt...) if err != nil { - bot.chalk.Done() - bot.chalk.Fail(err) return s, err } - - bot.chalk.Done() } err := fmt.Errorf("rejected") - bot.chalk.Fail(err) return s, err } diff --git a/agent/nanobot/runtime.go b/agent/nanobot/runtime.go index ed74eee..41cb474 100644 --- a/agent/nanobot/runtime.go +++ b/agent/nanobot/runtime.go @@ -24,6 +24,8 @@ type LLMs interface { Model(string) (chatter.Chatter, bool) } +const chalkboard = "io.console.chalkboard" + // Chalk is a structured progress-reporting sink. Implementations can write to // a terminal, a log file, or any other destination. The no-op default is used // when no output is configured. @@ -42,14 +44,6 @@ type Bot[S, A any] interface { Prompt(ctx context.Context, input S, opt ...chatter.Opt) (A, error) } -type devnull struct{} - -func (d devnull) Sub(ctx context.Context) context.Context { return ctx } -func (d devnull) Task(ctx context.Context, format string, args ...any) {} -func (d devnull) Done(...string) {} -func (d devnull) Fail(error) {} -func (d devnull) Printf(format string, args ...any) {} - // Runtime is the shared execution environment threaded through every agent // constructor. It bundles the file system (for prompt templates), the LLM // registry, an optional command registry for tool use, and the progress @@ -58,18 +52,14 @@ type Runtime struct { FileSystem fs.FS LLMs LLMs Registry *command.Registry - Chalk Chalk } // NewRuntime creates a Runtime with the given file system and LLM registry. // Progress output is silenced by default; call WithStdout to enable it. func NewRuntime(fs fs.FS, llms LLMs) *Runtime { - var chalk Chalk = devnull{} - return &Runtime{ FileSystem: fs, LLMs: llms, - Chalk: chalk, } } @@ -81,7 +71,6 @@ func (rt *Runtime) WithFileSystem(fs fs.FS) *Runtime { FileSystem: fs, LLMs: rt.LLMs, Registry: rt.Registry, - Chalk: rt.Chalk, } } @@ -93,17 +82,6 @@ func (rt *Runtime) WithRegistry(r *command.Registry) *Runtime { FileSystem: rt.FileSystem, LLMs: rt.LLMs, Registry: r, - Chalk: rt.Chalk, - } -} - -// WithStdout returns a copy of the runtime that reports progress to c. -func (rt *Runtime) WithStdout(c Chalk) *Runtime { - return &Runtime{ - FileSystem: rt.FileSystem, - LLMs: rt.LLMs, - Registry: rt.Registry, - Chalk: c, } } @@ -209,6 +187,25 @@ func (f Arr[S]) When(pred func(S) bool) Arr[S] { } } +// WithTask wraps the arrow with a progress report. The task name is fixed const string. +func (f Arr[S]) WithTask(name string) Arr[S] { return f.WithTaskf(func(S) string { return name }) } + +// WithTaskf wraps the arrow with a progress report. +// The task name is generated by applying fn to the current state S at the time of execution. +// The task is automatically marked done when the arrow returns, even if it returns an error. +func (f Arr[S]) WithTaskf(fn func(S) string) Arr[S] { + return func(ctx context.Context, s S, opt ...chatter.Opt) (S, error) { + c, ok := ctx.Value(chalkboard).(Chalk) + if !ok || c == nil { + return f(ctx, s, opt...) + } + + c.Task(ctx, fn(s)) + defer c.Done() + return f(c.Sub(ctx), s, opt...) + } +} + // Arrow lifts Bot[S, A] into the Kleisli arrow Arr[S] using the supplied Eff. // If no Eff is given, Lens is derived automatically from the first field of S // that has type A (via optics.ForProduct1); Eval defaults to the identity. diff --git a/agent/nanobot/thinkreact.go b/agent/nanobot/thinkreact.go index 2911061..d42cb79 100644 --- a/agent/nanobot/thinkreact.go +++ b/agent/nanobot/thinkreact.go @@ -10,8 +10,6 @@ package nanobot import ( "context" - "fmt" - "strings" "github.com/kshard/chatter" ) @@ -66,7 +64,6 @@ type BotThinkReAct[S, T any] struct { think Bot[S, []T] react Arr[T] gather func(S, []T) S - chalk Chalk } // ThinkReAct creates a BotThinkReAct. Panics on error. If no gather function @@ -94,7 +91,6 @@ func NewThinkReAct[S, T any](rt *Runtime, think Bot[S, []T], react Arr[T], gathe think: think, react: react, gather: g, - chalk: rt.Chalk, }, nil } @@ -107,25 +103,14 @@ func (bot *BotThinkReAct[S, T]) Prompt(ctx context.Context, input S, opt ...chat return *new(S), err } - bot.chalk.Task(ctx, "Execute (%d subtasks)", len(tasks)) results := make([]T, len(tasks)) for i, task := range tasks { - str := fmt.Sprintf("%v", task) - str = strings.ReplaceAll(str, "\n", " ") - if len(str) > 40 { - str = str[:37] + "..." - } - bot.chalk.Task(bot.chalk.Sub(ctx), "#%d %s", i+1, str) - t, err := bot.react(ctx, task, opt...) if err != nil { - bot.chalk.Fail(err) return *new(S), err } results[i] = t - bot.chalk.Done() } - bot.chalk.Done() return bot.gather(input, results), nil } diff --git a/prompt/prompt.go b/prompt/prompt.go index e3a2cf1..58bce94 100644 --- a/prompt/prompt.go +++ b/prompt/prompt.go @@ -18,9 +18,6 @@ import ( // * expected input/output format (json schema) // * list of servers type Prompt struct { - // Human readable name of the prompt, used for logging - Name string - // Prompt template, using Golang template syntax, e.g. "What is the capital of {{.Country}}?" Prompt string @@ -57,7 +54,6 @@ type Server struct { } type yamlPrompt struct { - Name string `yaml:"name,omitempty"` Format string `yaml:"format,omitempty"` RunsOn string `yaml:"runs-on,omitempty"` Retry int `yaml:"retry,omitempty"` @@ -170,7 +166,6 @@ func toPrompt(raw *yamlPrompt, prompt string) *Prompt { } return &Prompt{ - Name: raw.Name, Prompt: prompt, RunsOn: raw.RunsOn, Retry: raw.Retry, From 7921c606094f02cba4bab38eb807ba04127c4484 Mon Sep 17 00:00:00 2001 From: Dmitry Kolesnikov Date: Sun, 26 Apr 2026 11:01:16 +0300 Subject: [PATCH 2/4] (fix) add unit test --- agent/nanobot/nanobot_test.go | 120 +++++++++++++++++++++++++++++++++- agent/nanobot/runtime.go | 3 +- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/agent/nanobot/nanobot_test.go b/agent/nanobot/nanobot_test.go index 1108188..f5f12fd 100644 --- a/agent/nanobot/nanobot_test.go +++ b/agent/nanobot/nanobot_test.go @@ -12,6 +12,7 @@ import ( "context" "errors" "testing" + "testing/fstest" "github.com/fogfish/it/v2" "github.com/kshard/chatter" @@ -66,9 +67,15 @@ type MockChalk struct { tasks []string dones int failed []error + subs int } -func (c *MockChalk) Sub(ctx context.Context) context.Context { return ctx } +type chalkCtxKey struct{} + +func (c *MockChalk) Sub(ctx context.Context) context.Context { + c.subs++ + return context.WithValue(ctx, chalkCtxKey{}, true) +} func (c *MockChalk) Task(_ context.Context, format string, _ ...any) { c.tasks = append(c.tasks, format) } @@ -339,6 +346,60 @@ func TestWhen(t *testing.T) { }) } +// ============================================================================= +// TestWithTask +// ============================================================================= + +func TestArrWithTask(t *testing.T) { + t.Run("Success", func(t *testing.T) { + chalk := &MockChalk{} + ctx := context.WithValue(context.Background(), "io.console.chalkboard", chalk) + + arr := nanobot.Lift(func(ctx context.Context, w Work) (Work, error) { + if ok, _ := ctx.Value(chalkCtxKey{}).(bool); !ok { + t.Fatalf("expected chalk sub-context to be used") + } + w.Result = "done" + return w, nil + }).WithTask("arr-step") + + result, err := arr.Prompt(ctx, Work{}) + it.Then(t).Should( + it.Nil(err), + it.Equal(result.Result, "done"), + it.Equal(len(chalk.tasks), 1), + it.Equal(chalk.tasks[0], "arr-step"), + it.Equal(chalk.dones, 1), + it.Equal(len(chalk.failed), 0), + it.Equal(chalk.subs, 1), + ) + }) + + t.Run("ErrorStillDone", func(t *testing.T) { + errArr := errors.New("arr error") + chalk := &MockChalk{} + ctx := context.WithValue(context.Background(), "io.console.chalkboard", chalk) + + arr := nanobot.Lift(func(ctx context.Context, w Work) (Work, error) { + if ok, _ := ctx.Value(chalkCtxKey{}).(bool); !ok { + t.Fatalf("expected chalk sub-context to be used") + } + return w, errArr + }).WithTask("arr-step") + + _, err := arr.Prompt(ctx, Work{}) + it.Then(t).ShouldNot(it.Nil(err)) + it.Then(t).Should( + it.True(errors.Is(err, errArr)), + it.Equal(len(chalk.tasks), 1), + it.Equal(chalk.tasks[0], "arr-step"), + it.Equal(chalk.dones, 1), + it.Equal(len(chalk.failed), 0), + it.Equal(chalk.subs, 1), + ) + }) +} + // ============================================================================= // TestReflect // ============================================================================= @@ -817,3 +878,60 @@ func TestJsonify(t *testing.T) { it.Then(t).ShouldNot(it.Nil(err)) }) } + +// ============================================================================= +// TestReActWithTask +// ============================================================================= + +func TestReActWithTask(t *testing.T) { + newBot := func(t *testing.T, llm chatter.Chatter) *nanobot.BotReAct[Work, string] { + t.Helper() + + fs := fstest.MapFS{ + "react.prompt": &fstest.MapFile{ + Data: []byte("Return {{.Result}}"), + }, + } + + bot, err := nanobot.NewReAct[Work, string](nanobot.NewRuntime(fs, &MockLLMs{ + models: map[string]chatter.Chatter{"base": llm}, + }), "react.prompt") + it.Then(t).Should(it.Nil(err)) + + return bot.WithTask("react-step") + } + + t.Run("Success", func(t *testing.T) { + chalk := &MockChalk{} + ctx := context.WithValue(context.Background(), "io.console.chalkboard", chalk) + bot := newBot(t, &MockChatter{response: "final answer"}) + + result, err := bot.Prompt(ctx, Work{Result: "input"}) + it.Then(t).Should( + it.Nil(err), + it.Equal(result, "final answer"), + it.Equal(len(chalk.tasks), 1), + it.Equal(chalk.tasks[0], "react-step"), + it.Equal(chalk.dones, 1), + it.Equal(len(chalk.failed), 0), + ) + }) + + t.Run("FailureCallsFail", func(t *testing.T) { + errLLM := errors.New("llm failure") + chalk := &MockChalk{} + ctx := context.WithValue(context.Background(), "io.console.chalkboard", chalk) + bot := newBot(t, &MockChatter{err: errLLM}) + + _, err := bot.Prompt(ctx, Work{Result: "input"}) + it.Then(t).ShouldNot(it.Nil(err)) + it.Then(t).Should( + it.True(errors.Is(err, errLLM)), + it.Equal(len(chalk.tasks), 1), + it.Equal(chalk.tasks[0], "react-step"), + it.Equal(chalk.dones, 0), + it.Equal(len(chalk.failed), 1), + it.True(errors.Is(chalk.failed[0], errLLM)), + ) + }) +} diff --git a/agent/nanobot/runtime.go b/agent/nanobot/runtime.go index 41cb474..5d57460 100644 --- a/agent/nanobot/runtime.go +++ b/agent/nanobot/runtime.go @@ -34,7 +34,6 @@ type Chalk interface { Task(context.Context, string, ...any) Done(...string) Fail(error) - Printf(format string, args ...any) } // Bot is the core building block of the nanobot package. Any agent that @@ -196,7 +195,7 @@ func (f Arr[S]) WithTask(name string) Arr[S] { return f.WithTaskf(func(S) string func (f Arr[S]) WithTaskf(fn func(S) string) Arr[S] { return func(ctx context.Context, s S, opt ...chatter.Opt) (S, error) { c, ok := ctx.Value(chalkboard).(Chalk) - if !ok || c == nil { + if !ok || c == nil || fn == nil { return f(ctx, s, opt...) } From 77c9eb6cfbbe29446f79283670e509c669b34ec8 Mon Sep 17 00:00:00 2001 From: Dmitry Kolesnikov Date: Sun, 26 Apr 2026 11:04:07 +0300 Subject: [PATCH 3/4] (fix) lint issue --- agent/nanobot/nanobot_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent/nanobot/nanobot_test.go b/agent/nanobot/nanobot_test.go index f5f12fd..9ffe03d 100644 --- a/agent/nanobot/nanobot_test.go +++ b/agent/nanobot/nanobot_test.go @@ -353,6 +353,7 @@ func TestWhen(t *testing.T) { func TestArrWithTask(t *testing.T) { t.Run("Success", func(t *testing.T) { chalk := &MockChalk{} + //lint:ignore SA1029 We use string keys to allow zero-dep discovery ctx := context.WithValue(context.Background(), "io.console.chalkboard", chalk) arr := nanobot.Lift(func(ctx context.Context, w Work) (Work, error) { @@ -378,6 +379,7 @@ func TestArrWithTask(t *testing.T) { t.Run("ErrorStillDone", func(t *testing.T) { errArr := errors.New("arr error") chalk := &MockChalk{} + //lint:ignore SA1029 We use string keys to allow zero-dep discovery ctx := context.WithValue(context.Background(), "io.console.chalkboard", chalk) arr := nanobot.Lift(func(ctx context.Context, w Work) (Work, error) { @@ -903,6 +905,7 @@ func TestReActWithTask(t *testing.T) { t.Run("Success", func(t *testing.T) { chalk := &MockChalk{} + //lint:ignore SA1029 We use string keys to allow zero-dep discovery ctx := context.WithValue(context.Background(), "io.console.chalkboard", chalk) bot := newBot(t, &MockChatter{response: "final answer"}) @@ -920,6 +923,7 @@ func TestReActWithTask(t *testing.T) { t.Run("FailureCallsFail", func(t *testing.T) { errLLM := errors.New("llm failure") chalk := &MockChalk{} + //lint:ignore SA1029 We use string keys to allow zero-dep discovery ctx := context.WithValue(context.Background(), "io.console.chalkboard", chalk) bot := newBot(t, &MockChatter{err: errLLM}) From 77f0876523cc77d1ce7384976838de9a373f92df Mon Sep 17 00:00:00 2001 From: Dmitry Kolesnikov Date: Sun, 26 Apr 2026 11:16:05 +0300 Subject: [PATCH 4/4] (fix) add done function --- agent/nanobot/react.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/agent/nanobot/react.go b/agent/nanobot/react.go index 56b6899..de41f19 100644 --- a/agent/nanobot/react.go +++ b/agent/nanobot/react.go @@ -42,6 +42,7 @@ type BotReAct[A, B any] struct { prompt *prompt.Prompt t *template.Template taskf func(A) string + donef func(B) string } // ReAct is like NewReAct but panics on error. @@ -132,8 +133,11 @@ func (bot *BotReAct[A, B]) WithTask(name string) *BotReAct[A, B] { return bot.WithTaskf(func(A) string { return name }) } -func (bot *BotReAct[A, B]) WithTaskf(fn func(A) string) *BotReAct[A, B] { - bot.taskf = fn +func (bot *BotReAct[A, B]) WithTaskf(taskf func(A) string, donef ...func(B) string) *BotReAct[A, B] { + bot.taskf = taskf + if len(donef) > 0 { + bot.donef = donef[0] + } return bot } @@ -157,7 +161,12 @@ func (bot *BotReAct[A, B]) Prompt(ctx context.Context, input A, opt ...chatter.O chalk.Fail(err) return val, err } - chalk.Done() + + if bot.donef != nil { + chalk.Done(bot.donef(val)) + } else { + chalk.Done() + } return val, nil }