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

Commit 8862e55

Browse files
committed
partial tool calls are implemented
1 parent 730cef9 commit 8862e55

File tree

8 files changed

+143
-60
lines changed

8 files changed

+143
-60
lines changed

lib/ai_bot/bot.rb

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,37 @@ def reply(context, &update_blk)
108108

109109
tool_halted = false
110110

111+
allow_partial_tool_calls = persona.allow_partial_tool_calls?
112+
existing_tools = Set.new
113+
111114
result =
112-
llm.generate(prompt, feature_name: "bot", **llm_kwargs) do |partial, cancel|
113-
tool = persona.find_tool(partial, bot_user: user, llm: llm, context: context)
115+
llm.generate(
116+
prompt,
117+
feature_name: "bot",
118+
partial_tool_calls: allow_partial_tool_calls,
119+
**llm_kwargs,
120+
) do |partial, cancel|
121+
tool =
122+
persona.find_tool(
123+
partial,
124+
bot_user: user,
125+
llm: llm,
126+
context: context,
127+
existing_tools: existing_tools,
128+
)
114129
tool = nil if tools_ran >= MAX_TOOLS
115130

116131
if tool.present?
132+
tool_call = partial
133+
if tool_call.partial?
134+
if tool.class.allow_partial_tool_calls?
135+
tool.partial_invoke
136+
update_blk.call("", cancel, tool.custom_raw, :partial_tool)
137+
end
138+
next
139+
end
140+
141+
existing_tools << tool
117142
tool_found = true
118143
# a bit hacky, but extra newlines do no harm
119144
if needs_newlines
@@ -125,9 +150,7 @@ def reply(context, &update_blk)
125150
tools_ran += 1
126151
ongoing_chain &&= tool.chain_next_response?
127152

128-
if !tool.chain_next_response?
129-
tool_halted = true
130-
end
153+
tool_halted = true if !tool.chain_next_response?
131154
else
132155
next if tool_halted
133156
needs_newlines = true
@@ -192,7 +215,7 @@ def process_tool(tool, raw_context, llm, cancel, update_blk, prompt, context)
192215
end
193216

194217
def invoke_tool(tool, llm, cancel, context, &update_blk)
195-
show_placeholder = !context[:skip_tool_details]
218+
show_placeholder = !context[:skip_tool_details] && !tool.class.allow_partial_tool_calls?
196219

197220
update_blk.call("", cancel, build_placeholder(tool.summary, "")) if show_placeholder
198221

lib/ai_bot/personas/persona.rb

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,18 @@ def craft_prompt(context, llm: nil)
200200
prompt
201201
end
202202

203-
def find_tool(partial, bot_user:, llm:, context:)
203+
def find_tool(partial, bot_user:, llm:, context:, existing_tools: [])
204204
return nil if !partial.is_a?(DiscourseAi::Completions::ToolCall)
205-
tool_instance(partial, bot_user: bot_user, llm: llm, context: context)
205+
tool_instance(partial, bot_user: bot_user, llm: llm, context: context, existing_tools: existing_tools)
206+
end
207+
208+
def allow_partial_tool_calls?
209+
available_tools.any? { |tool| tool.allow_partial_tool_calls? }
206210
end
207211

208212
protected
209213

210-
def tool_instance(tool_call, bot_user:, llm:, context:)
214+
def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:)
211215
function_id = tool_call.id
212216
function_name = tool_call.name
213217
return nil if function_name.nil?
@@ -241,14 +245,21 @@ def tool_instance(tool_call, bot_user:, llm:, context:)
241245
arguments[name.to_sym] = value if value
242246
end
243247

244-
tool_klass.new(
245-
arguments,
246-
tool_call_id: function_id || function_name,
247-
persona_options: options[tool_klass].to_h,
248-
bot_user: bot_user,
249-
llm: llm,
250-
context: context,
251-
)
248+
tool_instance = existing_tools.find { |t| t.name == function_name && t.tool_call_id == function_id }
249+
250+
if tool_instance
251+
tool_instance.parameters = arguments
252+
tool_instance
253+
else
254+
tool_klass.new(
255+
arguments,
256+
tool_call_id: function_id || function_name,
257+
persona_options: options[tool_klass].to_h,
258+
bot_user: bot_user,
259+
llm: llm,
260+
context: context,
261+
)
262+
end
252263
end
253264

254265
def strip_quotes(value)

lib/ai_bot/playground.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ def reply_to(post, custom_instructions: nil, &blk)
456456
raw = reply.dup
457457
raw << "\n\n" << placeholder if placeholder.present?
458458

459-
blk.call(partial) if blk && type != :tool_details
459+
blk.call(partial) if blk && type != :tool_details && type != :partial_tool
460460

461461
if stream_reply && !Discourse.redis.get(redis_stream_key)
462462
cancel&.call

lib/ai_bot/tools/create_artifact.rb

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ def self.signature
3838
}
3939
end
4040

41+
def self.allow_partial_tool_calls?
42+
true
43+
end
44+
45+
def partial_invoke
46+
@selected_tab = :html
47+
if @prev_parameters
48+
@selected_tab = parameters.keys.find { |k| @prev_parameters[k] != parameters[k] }
49+
end
50+
update_custom_html
51+
@prev_parameters = parameters.dup
52+
end
53+
4154
def invoke
4255
yield parameters[:name] || "Web Artifact"
4356
# Get the current post from context
@@ -61,34 +74,73 @@ def invoke
6174
)
6275

6376
if artifact.save
64-
tabs = {
65-
css: [css, "CSS"],
66-
js: [js, "JavaScript"],
67-
html: [html, "HTML"],
68-
preview: [
69-
"<iframe src=\"#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/#{artifact.id}\" width=\"100%\" height=\"500\" frameborder=\"0\"></iframe>",
70-
"Preview",
71-
],
72-
}
73-
74-
first = true
75-
html_tabs =
76-
tabs.map do |tab, (content, name)|
77-
selected = " data-selected" if first
78-
first = false
79-
(<<~HTML).strip
77+
update_custom_html(artifact)
78+
success_response(artifact)
79+
else
80+
error_response(artifact.errors.full_messages.join(", "))
81+
end
82+
end
83+
84+
def chain_next_response?
85+
@chain_next_response
86+
end
87+
88+
private
89+
90+
def update_custom_html(artifact = nil)
91+
html = parameters[:html_body].to_s
92+
css = parameters[:css].to_s
93+
js = parameters[:js].to_s
94+
95+
iframe =
96+
"<iframe src=\"#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/#{artifact.id}\" width=\"100%\" height=\"500\" frameborder=\"0\"></iframe>" if artifact
97+
98+
content = []
99+
100+
content << [:html, "### HTML\n\n```html\n#{html}\n```"] if html.present?
101+
102+
content << [:css, "### CSS\n\n```css\n#{css}\n```"] if css.present?
103+
104+
content << [:js, "### JavaScript\n\n```javascript\n#{js}\n```"] if js.present?
105+
106+
content << [:preview, "### Preview\n\n#{iframe}"] if iframe
107+
108+
content.sort_by! { |c| c[0] === @selected_tab ? 0 : 1 } if !artifact
109+
110+
self.custom_raw = content.map { |c| c[1] }.join("\n\n")
111+
end
112+
113+
def update_custom_html_old(artifact = nil)
114+
html = parameters[:html_body].to_s
115+
css = parameters[:css].to_s
116+
js = parameters[:js].to_s
117+
118+
tabs = { css: [css, "CSS"], js: [js, "JavaScript"], html: [html, "HTML"] }
119+
120+
if artifact
121+
iframe =
122+
"<iframe src=\"#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/#{artifact.id}\" width=\"100%\" height=\"500\" frameborder=\"0\"></iframe>"
123+
tabs[:preview] = [iframe, "Preview"]
124+
end
125+
126+
first = true
127+
html_tabs =
128+
tabs.map do |tab, (content, name)|
129+
selected = " data-selected" if first
130+
first = false
131+
(<<~HTML).strip
80132
<div class="ai-artifact-tab" data-#{tab}#{selected}>
81133
<a>#{name}</a>
82134
</div>
83135
HTML
84-
end
85-
86-
first = true
87-
html_panels =
88-
tabs.map do |tab, (content, name)|
89-
selected = " data-selected" if first
90-
first = false
91-
inner_content =
136+
end
137+
138+
first = true
139+
html_panels =
140+
tabs.map do |tab, (content, name)|
141+
selected = " data-selected" if (first || (!artifact && tab == @selected_tab))
142+
first = false
143+
inner_content =
92144
if tab == :preview
93145
content
94146
else
@@ -99,15 +151,15 @@ def invoke
99151
```
100152
HTML
101153
end
102-
(<<~HTML).strip
154+
(<<~HTML).strip
103155
<div class="ai-artifact-panel" data-#{tab}#{selected}>
104156
105157
#{inner_content}
106158
</div>
107159
HTML
108-
end
160+
end
109161

110-
self.custom_raw = <<~RAW
162+
self.custom_raw = <<~RAW
111163
<div class="ai-artifact">
112164
<div class="ai-artifact-tabs">
113165
#{html_tabs.join("\n")}
@@ -117,19 +169,8 @@ def invoke
117169
</div>
118170
</div>
119171
RAW
120-
121-
success_response(artifact)
122-
else
123-
error_response(artifact.errors.full_messages.join(", "))
124-
end
125172
end
126173

127-
def chain_next_response?
128-
@chain_next_response
129-
end
130-
131-
private
132-
133174
def success_response(artifact)
134175
@chain_next_response = false
135176
iframe_url = "#{Discourse.base_url}/discourse-ai/ai-bot/artifacts/#{artifact.id}"

lib/ai_bot/tools/tool.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,14 @@ def help
3838
def custom_system_message
3939
nil
4040
end
41+
42+
def allow_partial_tool_calls?
43+
false
44+
end
4145
end
4246

43-
attr_accessor :custom_raw
44-
attr_reader :tool_call_id, :persona_options, :bot_user, :llm, :context, :parameters
47+
attr_accessor :custom_raw, :parameters
48+
attr_reader :tool_call_id, :persona_options, :bot_user, :llm, :context
4549

4650
def initialize(
4751
parameters,

lib/completions/endpoints/base.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def perform_completion!(
9696
raise CompletionFailed, response.body
9797
end
9898

99-
xml_tool_processor = XmlToolProcessor.new if xml_tools_enabled? &&
99+
xml_tool_processor = XmlToolProcessor.new(partial_tool_calls: partial_tool_calls) if xml_tools_enabled? &&
100100
dialect.prompt.has_tools?
101101

102102
to_strip = xml_tags_to_strip(dialect)

lib/completions/endpoints/gemini.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def decode(str)
144144

145145
def decode(chunk)
146146
json = JSON.parse(chunk, symbolize_names: true)
147+
147148
idx = -1
148149
json
149150
.dig(:candidates, 0, :content, :parts)
@@ -168,7 +169,6 @@ def decode(chunk)
168169

169170
def decode_chunk(chunk)
170171
@tool_index ||= -1
171-
172172
streaming_decoder
173173
.decode(chunk)
174174
.map do |parsed|

lib/completions/tool_call.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ class ToolCall
66
attr_reader :id, :name, :parameters
77
attr_accessor :partial
88

9+
def partial?
10+
!!@partial
11+
end
12+
913
def initialize(id:, name:, parameters: nil)
1014
@id = id
1115
@name = name

0 commit comments

Comments
 (0)