fix(session): give host tools the product scope of their turn

A harness may call a tool with a context that carries nothing of the
turn. Lend the values of the turn to the tool, so a status read in the
tool shares the turn's observation instead of starting without one.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:31:37 +00:00
1 parent a728722c29
commit da60e435be
3 files changed
+48 -2

No files matched your search

+19 -1
View File
@@ -1390,7 +1390,10 @@ func (c *Chat) guardTools(tools []Tool) []Tool {
if tctx == nil {
tctx = c.m.ctx
}
rctx, cancel := context.WithCancel(ctx)
// The harness context carries none of the product state of the turn
// (see Options.ProductScope), so a tool would not see it. Lend the
// values of the turn to the tool.
rctx, cancel := context.WithCancel(turnValues{Context: ctx, turn: tctx})
defer cancel()
defer context.AfterFunc(tctx, cancel)()
return run(rctx, args)
@@ -1418,3 +1421,18 @@ func (c *Chat) releaseMemory(ctx context.Context) {
c.bus = closedBus()
c.evMu.Unlock()
}
// turnValues is a context whose values fall back to those of the turn: the
// product state ProductScope attached, which the harness does not pass on.
// Deadline and cancellation stay those of the embedded context.
type turnValues struct {
context.Context
turn context.Context
}
func (t turnValues) Value(key any) any {
if v := t.Context.Value(key); v != nil {
return v
}
return t.turn.Value(key)
}
@@ -123,6 +123,26 @@ var _ = Describe("product request scopes", func() {
Expect(got).To(Equal([]model.ObservationID{"1", "2", "2", "2", "3", "3", "3"}))
})
It("gives a tool the product scope of the turn when the harness calls it with its own context", func() {
seen := make(chan bool, 1)
o := session.Options{Harness: &sessiontest.FakeHarness{OwnToolContext: true, Turns: [][]sessiontest.Step{{{Tool: "status", Args: "{}"}}}}}
o.ProductScope = func(ctx context.Context, _ session.Access) (context.Context, func()) {
return context.WithValue(ctx, productScopeKey{}, &productScope{}), func() {}
}
o.ChatTools = func(context.Context, session.Access) (session.TurnTools, error) {
return session.TurnTools{Tools: []session.Tool{{Name: "status", Description: "status", Schema: json.RawMessage(`{"type":"object"}`),
Run: func(ctx context.Context, _ json.RawMessage) (string, error) {
_, ok := ctx.Value(productScopeKey{}).(*productScope)
seen <- ok
return "ok", nil
}}}}, nil
}
chat := newChat(o)
Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed())
waitTurn(chat)
Expect(<-seen).To(BeTrue(), "the tool sees the scope of its turn")
})
for _, stop := range []bool{false, true} {
It(fmt.Sprintf("closes pending observations at the real turn boundary (stop=%t)", stop), func() {
errors := make(chan error, 8)
@@ -44,6 +44,10 @@ type FakeHarness struct {
// FailNextStarts makes that many of the next Starts fail. Set it while no
// Start runs.
FailNextStarts int
// OwnToolContext makes the harness call a tool with a context of its own,
// derived from nothing of the turn, as a harness that receives tool calls
// from another process does.
OwnToolContext bool
mu sync.Mutex
envs []session.HarnessEnv
@@ -172,7 +176,11 @@ func (s *fakeSession) Send(ctx context.Context, text string) error {
args := json.RawMessage(st.Args)
ev := &session.ToolEvent{Name: st.Tool, Summary: session.Summarize(args)}
s.env.Emit(session.Event{Kind: session.KindToolStart, Tool: ev})
out, err := tl.Run(ctx, args)
toolCtx := ctx
if s.h.OwnToolContext {
toolCtx = context.Background()
}
out, err := tl.Run(toolCtx, args)
end := *ev
end.OK, end.Output = err == nil, session.CapText(out, 2048)
if err != nil {