Skip to content

Commit bf4010d

Browse files
LocalAI [bot]mudlerclaude
authored
fix(tools): carry the parked reply text in the WithOnPark callback (#59)
The no-tool reply the model produces right before a park gate was only recorded inside the fragment; embedders had no way to read it at park time. nib worked around this by capturing the reasoning callback, but toolSelection fires that callback with resp.ReasoningContent (reasoning tokens, empty for most models), not the reply text — so parked replies (e.g. the answer to a mid-run injected user message) were silently dropped by the UI. WithOnPark now passes the assistant reply text that preceded the park: the no-tool reply at the agents-still-running gate, or "" at the sink-state gate where the reply is produced after the loop. Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 037dd82 commit bf4010d

3 files changed

Lines changed: 33 additions & 14 deletions

File tree

agent_internal_test.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,12 @@ func TestExecuteTools_ParksWhilePendingWork(t *testing.T) {
148148
}
149149

150150
// TestExecuteTools_OnParkOnResumeFire proves that WithOnPark fires immediately
151-
// before the loop blocks on the injection channel at a park gate, and
152-
// WithOnResume fires immediately after an injected message wakes it — and that
153-
// onPark is observed before onResume. While parked, onPark must have fired but
154-
// onResume must NOT yet; after the predicate flips false and a message is
155-
// injected, the loop resumes/returns and onResume must have fired.
151+
// before the loop blocks on the injection channel at a park gate — carrying the
152+
// assistant reply text that preceded the park — and WithOnResume fires
153+
// immediately after an injected message wakes it, with onPark observed before
154+
// onResume. While parked, onPark must have fired but onResume must NOT yet;
155+
// after the predicate flips false and a message is injected, the loop
156+
// resumes/returns and onResume must have fired.
156157
func TestExecuteTools_OnParkOnResumeFire(t *testing.T) {
157158
ch := make(chan openai.ChatCompletionMessage, 1)
158159
var pending atomic.Bool
@@ -162,12 +163,16 @@ func TestExecuteTools_OnParkOnResumeFire(t *testing.T) {
162163
// whether onPark had already fired — used to assert ordering.
163164
var parkBeforeResume atomic.Bool
164165
parkBeforeResume.Store(true)
166+
var parkReply atomic.Value // first parked reply text
165167
done := make(chan struct{})
166168
go func() {
167169
_, _ = ExecuteTools(noToolMockLLM{}, NewEmptyFragment().AddMessage("user", "hi"),
168170
WithMessageInjectionChan(ch),
169171
WithPendingWork(func() bool { return pending.Load() }),
170-
WithOnPark(func() { parks.Add(1) }),
172+
WithOnPark(func(reply string) {
173+
parkReply.CompareAndSwap(nil, reply)
174+
parks.Add(1)
175+
}),
171176
WithOnResume(func() {
172177
if parks.Load() == 0 {
173178
parkBeforeResume.Store(false)
@@ -206,4 +211,9 @@ func TestExecuteTools_OnParkOnResumeFire(t *testing.T) {
206211
if !parkBeforeResume.Load() {
207212
t.Fatal("onPark must fire before onResume")
208213
}
214+
// The park gate must carry the no-tool reply the model produced right
215+
// before blocking — the embedder surfaces it as the parked reply.
216+
if got, _ := parkReply.Load().(string); got != "sub-agent done" {
217+
t.Fatalf("onPark reply = %q, want %q", got, "sub-agent done")
218+
}
209219
}

options.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,11 @@ type Options struct {
6464
pendingWork func() bool
6565

6666
// onPark, when set, fires immediately before the loop blocks on the
67-
// message-injection channel at a park gate. onResume, when set, fires
68-
// immediately after an injected message wakes the loop at a park gate.
69-
onPark func()
67+
// message-injection channel at a park gate; it receives the assistant
68+
// reply text that preceded the park ("" when the model produced none).
69+
// onResume, when set, fires immediately after an injected message wakes
70+
// the loop at a park gate.
71+
onPark func(reply string)
7072
onResume func()
7173

7274
// TODO-based iterative execution options
@@ -427,12 +429,15 @@ func WithPendingWork(fn func() bool) Option { return func(o *Options) { o.pendin
427429
// WithOnPark registers a callback fired immediately BEFORE the loop blocks on
428430
// the message-injection channel at a park gate (i.e. when background work —
429431
// cogito's own running agents or an embedder's WithPendingWork predicate — is
430-
// still pending). An embedder can use this to finalize the current assistant
431-
// turn the instant the loop parks.
432+
// still pending). The callback receives the assistant reply text that preceded
433+
// the park — the no-tool text reply recorded in the fragment just before the
434+
// loop blocked — or "" when the model produced none (e.g. a sink-state park).
435+
// An embedder can use this to surface the parked reply and finalize the
436+
// current assistant turn the instant the loop parks.
432437
//
433438
// Across a single run the loop may park and resume multiple times (e.g. several
434439
// injected messages), so onPark may fire multiple times — that is expected.
435-
func WithOnPark(fn func()) Option { return func(o *Options) { o.onPark = fn } }
440+
func WithOnPark(fn func(reply string)) Option { return func(o *Options) { o.onPark = fn } }
436441

437442
// WithOnResume registers a callback fired immediately AFTER an injected message
438443
// wakes the loop at a park gate (the resume path). It does NOT fire when the

tools.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,7 +1442,9 @@ TOOL_LOOP:
14421442
if (o.agentManager != nil && o.agentManager.HasRunning()) || (o.pendingWork != nil && o.pendingWork()) {
14431443
xlog.Debug("No tool selected but background agents still running, blocking for completions")
14441444
if o.onPark != nil {
1445-
o.onPark()
1445+
// reasoning holds the no-tool text reply recorded in the
1446+
// fragment above — the parked reply the embedder surfaces.
1447+
o.onPark(reasoning)
14461448
}
14471449
select {
14481450
case <-o.context.Done():
@@ -1557,7 +1559,9 @@ TOOL_LOOP:
15571559
xlog.Debug("Sink state selected but background agents still running, blocking for completions")
15581560
hasSinkState = false // Reset so we re-enter the loop
15591561
if o.onPark != nil {
1560-
o.onPark()
1562+
// Sink-state park: the reply is produced by the sink state
1563+
// after the loop, so there is no parked reply text yet.
1564+
o.onPark("")
15611565
}
15621566
select {
15631567
case <-o.context.Done():

0 commit comments

Comments
 (0)