Skip to content

Commit ca42c2b

Browse files
committed
Allow read-only tools during fan-out and preserve tool history in conversation
Two fixes for "providers not receiving tool execution results": 1. Fan-out now supports read-only tools (file_read). Providers can inspect the codebase during their initial response instead of giving generic advice. Write/exec tools remain synthesis-only. Fan-out tool calls are executed inline with up to 3 re-query rounds per provider. 2. Tool calls and results are now preserved as structured messages in the conversation state (assistant+ToolCalls → RoleTool results → follow-up). Previously flattened to a text blob, losing tool context for subsequent turns. ToolLoop.RunWithMessages returns the appended messages for the caller to store in conv.
1 parent d4b1195 commit ca42c2b

7 files changed

Lines changed: 227 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
66

77
## [Unreleased]
88

9+
## [1.13.0] - 2026-03-23
10+
11+
### Added
12+
- **Read-only tools during fan-out**: Providers can now use `file_read` during fan-out to inspect the codebase before responding, instead of giving generic advice. Write and exec tools remain synthesis-only.
13+
14+
### Fixed
15+
- **Tool results lost from conversation history**: Tool calls and results are now preserved as structured messages in the conversation state (assistant+tool_calls → tool results → follow-up). Previously they were flattened to a single text blob, so providers lost tool context on subsequent turns.
16+
917
## [1.12.1] - 2026-03-22
1018

1119
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Every AI model has blind spots. Claude might excel at architecture, GPT at debug
2929

3030
### Multi-Provider Consensus
3131

32-
Query every configured LLM simultaneously. Responses fan out in parallel (latency = slowest provider, not sum of all), and your designated **primary** model synthesizes them into a single answer — identifying areas of agreement, unique insights, and errors.
32+
Query every configured LLM simultaneously. Responses fan out in parallel (latency = slowest provider, not sum of all), and your designated **primary** model synthesizes them into a single answer — identifying areas of agreement, unique insights, and errors. Providers can read files during fan-out to give codebase-aware answers.
3333

3434
### Supported Providers
3535

cmd/polycode/app.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,20 @@ func startTUI(cfg *config.Config) error {
740740
synthesisMode,
741741
)
742742

743+
// Allow read-only tools (file_read) during fan-out so providers
744+
// can inspect the codebase to give informed answers.
745+
fanOutExecutor := action.NewExecutor(nil, 30*time.Second)
746+
queryPipeline.SetFanOutTools(
747+
action.ReadOnlyTools(),
748+
func(call provider.ToolCall) (string, error) {
749+
result := fanOutExecutor.Execute(call)
750+
if result.Error != nil {
751+
return result.Output, result.Error
752+
}
753+
return result.Output, nil
754+
},
755+
)
756+
743757
// Accumulate fan-out text per provider from the live (untruncated) stream.
744758
// The chunk callback fires from concurrent goroutines, so this needs a mutex.
745759
var fanoutMu sync.Mutex
@@ -960,8 +974,11 @@ func startTUI(cfg *config.Config) error {
960974
}
961975

962976
// NOTE: Don't append fullResponse to conv yet — if tool execution
963-
// follows, we want to combine initial text + tool output into one
964-
// assistant message so future turns have full context.
977+
// follows, we want to preserve the structured tool call/result
978+
// messages in the conversation state for future turns.
979+
var toolResponse string
980+
var toolLoopMsgs []provider.Message
981+
var consensusText string // consensus text before tool output (for structured conv state)
965982

966983
// Execute tool calls if the consensus response included them
967984
if len(pendingToolCalls) > 0 {
@@ -1068,13 +1085,14 @@ func startTUI(cfg *config.Config) error {
10681085
toolOut := make(chan provider.StreamChunk, 16)
10691086
go func() {
10701087
defer toolCancel()
1071-
if err := toolLoop.Run(toolCtx, toolMsgs, pendingToolCalls, opts, toolOut); err != nil {
1088+
loopMsgs, err := toolLoop.RunWithMessages(toolCtx, toolMsgs, pendingToolCalls, opts, toolOut)
1089+
toolLoopMsgs = loopMsgs // writes to outer var; safe because consumer drains toolOut first
1090+
if err != nil {
10721091
toolOut <- provider.StreamChunk{Error: err}
10731092
}
10741093
close(toolOut)
10751094
}()
10761095

1077-
var toolResponse string
10781096
var toolLoopOK bool
10791097
var wroteFiles bool
10801098
for chunk := range toolOut {
@@ -1111,7 +1129,10 @@ func startTUI(cfg *config.Config) error {
11111129
}
11121130
toolCancel()
11131131

1114-
// Combine initial consensus text + tool follow-up
1132+
// Save consensus-only text before combining (needed for structured conv state).
1133+
consensusText = fullResponse
1134+
1135+
// Combine initial consensus text + tool follow-up for display/history.
11151136
if toolResponse != "" {
11161137
fullResponse += "\n" + toolResponse
11171138
}
@@ -1188,9 +1209,20 @@ func startTUI(cfg *config.Config) error {
11881209
program.Send(tui.ConsensusChunkMsg{Done: true})
11891210
}
11901211

1191-
// Append the complete assistant response (initial consensus + tool output)
1192-
// as a single message so future turns have full context
1193-
if fullResponse != "" {
1212+
// Append conversation messages so future turns have full context.
1213+
// When tool calls occurred, preserve the structured message sequence
1214+
// (assistant+tool_calls → tool results → follow-up assistant) so
1215+
// providers with native tool support get proper conversation history.
1216+
if len(pendingToolCalls) > 0 && len(toolLoopMsgs) > 0 {
1217+
// Initial assistant message with tool calls
1218+
conv.append(provider.Message{
1219+
Role: provider.RoleAssistant,
1220+
Content: consensusText,
1221+
ToolCalls: pendingToolCalls,
1222+
})
1223+
// Tool results and follow-up messages from the tool loop
1224+
conv.append(toolLoopMsgs...)
1225+
} else if fullResponse != "" {
11941226
conv.append(provider.Message{
11951227
Role: provider.RoleAssistant,
11961228
Content: fullResponse,

internal/action/loop.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,38 @@ func (l *ToolLoop) Run(
3333
opts provider.QueryOpts,
3434
out chan<- provider.StreamChunk,
3535
) error {
36+
_, err := l.RunWithMessages(ctx, messages, toolCalls, opts, out)
37+
return err
38+
}
39+
40+
// RunWithMessages is like Run but also returns the messages appended during
41+
// the tool loop (tool results + follow-up assistant messages). These can be
42+
// used to preserve structured tool call history in the conversation state.
43+
func (l *ToolLoop) RunWithMessages(
44+
ctx context.Context,
45+
messages []provider.Message,
46+
toolCalls []provider.ToolCall,
47+
opts provider.QueryOpts,
48+
out chan<- provider.StreamChunk,
49+
) ([]provider.Message, error) {
3650
// Work on a copy so we don't mutate the caller's slice.
3751
msgs := make([]provider.Message, len(messages))
3852
copy(msgs, messages)
53+
initialLen := len(msgs)
3954

4055
currentCalls := toolCalls
4156

57+
appended := func() []provider.Message {
58+
return msgs[initialLen:]
59+
}
60+
4261
for {
4362
if len(currentCalls) == 0 {
4463
select {
4564
case out <- provider.StreamChunk{Done: true}:
4665
case <-ctx.Done():
4766
}
48-
return nil
67+
return appended(), nil
4968
}
5069

5170
// The assistant message with ToolCalls is already in msgs:
@@ -61,7 +80,7 @@ func (l *ToolLoop) Run(
6180
Status: true,
6281
}:
6382
case <-ctx.Done():
64-
return ctx.Err()
83+
return appended(), ctx.Err()
6584
}
6685

6786
result := l.executor.Execute(call)
@@ -86,7 +105,7 @@ func (l *ToolLoop) Run(
86105
Status: true,
87106
}:
88107
case <-ctx.Done():
89-
return ctx.Err()
108+
return appended(), ctx.Err()
90109
}
91110
}
92111

@@ -101,22 +120,22 @@ func (l *ToolLoop) Run(
101120
// Send updated conversation back to the model.
102121
stream, err := l.primary.Query(ctx, msgs, opts)
103122
if err != nil {
104-
return fmt.Errorf("tool loop: %w", err)
123+
return appended(), fmt.Errorf("tool loop: %w", err)
105124
}
106125

107126
// Stream response chunks live to output, collecting for conversation state.
108127
var responseContent string
109128
var newToolCalls []provider.ToolCall
110129
for chunk := range stream {
111130
if chunk.Error != nil {
112-
return fmt.Errorf("tool loop: %w", chunk.Error)
131+
return appended(), fmt.Errorf("tool loop: %w", chunk.Error)
113132
}
114133
if chunk.Delta != "" {
115134
responseContent += chunk.Delta
116135
select {
117136
case out <- provider.StreamChunk{Delta: chunk.Delta}:
118137
case <-ctx.Done():
119-
return ctx.Err()
138+
return appended(), ctx.Err()
120139
}
121140
}
122141
newToolCalls = append(newToolCalls, chunk.ToolCalls...)
@@ -147,6 +166,6 @@ func (l *ToolLoop) Run(
147166
case out <- provider.StreamChunk{Done: true}:
148167
case <-ctx.Done():
149168
}
150-
return nil
169+
return appended(), nil
151170
}
152171
}

internal/action/tools.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,11 @@ func AllTools() []provider.ToolDefinition {
7272
ShellExecTool(),
7373
}
7474
}
75+
76+
// ReadOnlyTools returns tool definitions that are safe for concurrent fan-out
77+
// execution — read-only operations with no side effects.
78+
func ReadOnlyTools() []provider.ToolDefinition {
79+
return []provider.ToolDefinition{
80+
FileReadTool(),
81+
}
82+
}

0 commit comments

Comments
 (0)