Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 823e8ef

Browse files
authored
FEATURE: partial tool call support for OpenAI and Anthropic (#908)
Implement streaming tool call implementation for Anthropic and Open AI. When calling: llm.generate(..., partial_tool_calls: true) do ... Partials may contain ToolCall instances with partial: true, These tool calls are partially populated with json partially parsed. So for example when performing a search you may get: ToolCall(..., {search: "hello" }) ToolCall(..., {search: "hello world" }) The library used to parse json is: https://github.com/dgraham/json-stream We use a fork cause we need access to the internal buffer. This prepares internals to perform partial tool calls, but does not implement it yet.
1 parent f75b13c commit 823e8ef

File tree

17 files changed

+844
-45
lines changed

17 files changed

+844
-45
lines changed

lib/ai_bot/bot.rb

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,20 +185,23 @@ def process_tool(tool, raw_context, llm, cancel, update_blk, prompt, context)
185185
end
186186

187187
def invoke_tool(tool, llm, cancel, context, &update_blk)
188-
update_blk.call("", cancel, build_placeholder(tool.summary, ""))
188+
show_placeholder = !context[:skip_tool_details]
189+
190+
update_blk.call("", cancel, build_placeholder(tool.summary, "")) if show_placeholder
189191

190192
result =
191193
tool.invoke do |progress|
192-
placeholder = build_placeholder(tool.summary, progress)
193-
update_blk.call("", cancel, placeholder)
194+
if show_placeholder
195+
placeholder = build_placeholder(tool.summary, progress)
196+
update_blk.call("", cancel, placeholder)
197+
end
194198
end
195199

196-
tool_details = build_placeholder(tool.summary, tool.details, custom_raw: tool.custom_raw)
197-
198-
if context[:skip_tool_details] && tool.custom_raw.present?
199-
update_blk.call(tool.custom_raw, cancel, nil, :custom_raw)
200-
elsif !context[:skip_tool_details]
200+
if show_placeholder
201+
tool_details = build_placeholder(tool.summary, tool.details, custom_raw: tool.custom_raw)
201202
update_blk.call(tool_details, cancel, nil, :tool_details)
203+
elsif tool.custom_raw.present?
204+
update_blk.call(tool.custom_raw, cancel, nil, :custom_raw)
202205
end
203206

204207
result

lib/ai_bot/playground.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ def reply_to(post, custom_instructions: nil, &blk)
452452
bot.reply(context) do |partial, cancel, placeholder, type|
453453
reply << partial
454454
raw = reply.dup
455-
raw << "\n\n" << placeholder if placeholder.present? && !context[:skip_tool_details]
455+
raw << "\n\n" << placeholder if placeholder.present?
456456

457457
blk.call(partial) if blk && type != :tool_details
458458

lib/completions/anthropic_message_processor.rb

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,50 @@ class DiscourseAi::Completions::AnthropicMessageProcessor
44
class AnthropicToolCall
55
attr_reader :name, :raw_json, :id
66

7-
def initialize(name, id)
7+
def initialize(name, id, partial_tool_calls: false)
88
@name = name
99
@id = id
1010
@raw_json = +""
11+
@tool_call = DiscourseAi::Completions::ToolCall.new(id: id, name: name, parameters: {})
12+
@streaming_parser =
13+
DiscourseAi::Completions::ToolCallProgressTracker.new(self) if partial_tool_calls
1114
end
1215

1316
def append(json)
1417
@raw_json << json
18+
@streaming_parser << json if @streaming_parser
19+
end
20+
21+
def notify_progress(key, value)
22+
@tool_call.partial = true
23+
@tool_call.parameters[key.to_sym] = value
24+
@has_new_data = true
25+
end
26+
27+
def has_partial?
28+
@has_new_data
29+
end
30+
31+
def partial_tool_call
32+
@has_new_data = false
33+
@tool_call
1534
end
1635

1736
def to_tool_call
1837
parameters = JSON.parse(raw_json, symbolize_names: true)
19-
DiscourseAi::Completions::ToolCall.new(id: id, name: name, parameters: parameters)
38+
@tool_call.partial = false
39+
@tool_call.parameters = parameters
40+
@tool_call
2041
end
2142
end
2243

2344
attr_reader :tool_calls, :input_tokens, :output_tokens
2445

25-
def initialize(streaming_mode:)
46+
def initialize(streaming_mode:, partial_tool_calls: false)
2647
@streaming_mode = streaming_mode
2748
@tool_calls = []
2849
@current_tool_call = nil
50+
@partial_tool_calls = partial_tool_calls
2951
end
3052

3153
def to_tool_calls
@@ -38,11 +60,17 @@ def process_streamed_message(parsed)
3860
tool_name = parsed.dig(:content_block, :name)
3961
tool_id = parsed.dig(:content_block, :id)
4062
result = @current_tool_call.to_tool_call if @current_tool_call
41-
@current_tool_call = AnthropicToolCall.new(tool_name, tool_id) if tool_name
63+
@current_tool_call =
64+
AnthropicToolCall.new(
65+
tool_name,
66+
tool_id,
67+
partial_tool_calls: @partial_tool_calls,
68+
) if tool_name
4269
elsif parsed[:type] == "content_block_start" || parsed[:type] == "content_block_delta"
4370
if @current_tool_call
4471
tool_delta = parsed.dig(:delta, :partial_json).to_s
4572
@current_tool_call.append(tool_delta)
73+
result = @current_tool_call.partial_tool_call if @current_tool_call.has_partial?
4674
else
4775
result = parsed.dig(:delta, :text).to_s
4876
end

lib/completions/endpoints/anthropic.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ def decode(response_data)
107107

108108
def processor
109109
@processor ||=
110-
DiscourseAi::Completions::AnthropicMessageProcessor.new(streaming_mode: @streaming_mode)
110+
DiscourseAi::Completions::AnthropicMessageProcessor.new(
111+
streaming_mode: @streaming_mode,
112+
partial_tool_calls: partial_tool_calls,
113+
)
111114
end
112115

113116
def has_tool?(_response_data)

lib/completions/endpoints/base.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module DiscourseAi
44
module Completions
55
module Endpoints
66
class Base
7+
attr_reader :partial_tool_calls
8+
79
CompletionFailed = Class.new(StandardError)
810
TIMEOUT = 60
911

@@ -58,8 +60,10 @@ def perform_completion!(
5860
model_params = {},
5961
feature_name: nil,
6062
feature_context: nil,
63+
partial_tool_calls: false,
6164
&blk
6265
)
66+
@partial_tool_calls = partial_tool_calls
6367
model_params = normalize_model_params(model_params)
6468
orig_blk = blk
6569

lib/completions/endpoints/canned_response.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ def perform_completion!(
2828
_user,
2929
_model_params,
3030
feature_name: nil,
31-
feature_context: nil
31+
feature_context: nil,
32+
partial_tool_calls: false
3233
)
3334
@dialect = dialect
3435
response = responses[completions]

lib/completions/endpoints/fake.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ def perform_completion!(
120120
user,
121121
model_params = {},
122122
feature_name: nil,
123-
feature_context: nil
123+
feature_context: nil,
124+
partial_tool_calls: false
124125
)
125126
last_call = { dialect: dialect, user: user, model_params: model_params }
126127
self.class.last_call = last_call

lib/completions/endpoints/open_ai.rb

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def perform_completion!(
3333
model_params = {},
3434
feature_name: nil,
3535
feature_context: nil,
36+
partial_tool_calls: false,
3637
&blk
3738
)
3839
if dialect.respond_to?(:is_gpt_o?) && dialect.is_gpt_o? && block_given?
@@ -103,10 +104,16 @@ def decode(response_raw)
103104

104105
def decode_chunk(chunk)
105106
@decoder ||= JsonStreamDecoder.new
106-
(@decoder << chunk)
107-
.map { |parsed_json| processor.process_streamed_message(parsed_json) }
108-
.flatten
109-
.compact
107+
elements =
108+
(@decoder << chunk)
109+
.map { |parsed_json| processor.process_streamed_message(parsed_json) }
110+
.flatten
111+
.compact
112+
113+
# Remove duplicate partial tool calls
114+
# sometimes we stream weird chunks
115+
seen_tools = Set.new
116+
elements.select { |item| !item.is_a?(ToolCall) || seen_tools.add?(item) }
110117
end
111118

112119
def decode_chunk_finish
@@ -120,7 +127,7 @@ def xml_tools_enabled?
120127
private
121128

122129
def processor
123-
@processor ||= OpenAiMessageProcessor.new
130+
@processor ||= OpenAiMessageProcessor.new(partial_tool_calls: partial_tool_calls)
124131
end
125132
end
126133
end

0 commit comments

Comments
 (0)