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

Commit c352054

Browse files
authored
FIX: encode parameters returned from LLMs correctly (#889)
Fixes encoding of params on LLM function calls. Previously we would improperly return results if a function parameter returned an HTML tag. Additionally adds some missing HTTP verbs to tool calls.
1 parent 7e3a543 commit c352054

File tree

10 files changed

+110
-60
lines changed

10 files changed

+110
-60
lines changed

app/models/ai_tool.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ def self.preamble
7373
* Returns:
7474
* { status: number, body: string }
7575
*
76+
* (also available: http.put, http.patch, http.delete)
77+
*
7678
* Note: Max 20 HTTP requests per execution.
7779
*
7880
* 2. llm

lib/ai_bot/tool_runner.rb

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,14 @@ def mini_racer_context
4444
end
4545

4646
def framework_script
47+
http_methods = %i[get post put patch delete].map { |method| <<~JS }.join("\n")
48+
#{method}: function(url, options) {
49+
return _http_#{method}(url, options);
50+
},
51+
JS
4752
<<~JS
4853
const http = {
49-
get: function(url, options) { return _http_get(url, options) },
50-
post: function(url, options) { return _http_post(url, options) },
54+
#{http_methods}
5155
};
5256
5357
const llm = {
@@ -249,36 +253,44 @@ def attach_http(mini_racer_context)
249253
end,
250254
)
251255

252-
mini_racer_context.attach(
253-
"_http_post",
254-
->(url, options) do
255-
begin
256-
@http_requests_made += 1
257-
if @http_requests_made > MAX_HTTP_REQUESTS
258-
raise TooManyRequestsError.new("Tool made too many HTTP requests")
259-
end
256+
%i[post put patch delete].each do |method|
257+
mini_racer_context.attach(
258+
"_http_#{method}",
259+
->(url, options) do
260+
begin
261+
@http_requests_made += 1
262+
if @http_requests_made > MAX_HTTP_REQUESTS
263+
raise TooManyRequestsError.new("Tool made too many HTTP requests")
264+
end
260265

261-
self.running_attached_function = true
262-
headers = (options && options["headers"]) || {}
263-
body = options && options["body"]
266+
self.running_attached_function = true
267+
headers = (options && options["headers"]) || {}
268+
body = options && options["body"]
269+
270+
result = {}
271+
DiscourseAi::AiBot::Tools::Tool.send_http_request(
272+
url,
273+
method: method,
274+
headers: headers,
275+
body: body,
276+
) do |response|
277+
result[:body] = response.body
278+
result[:status] = response.code.to_i
279+
end
264280

265-
result = {}
266-
DiscourseAi::AiBot::Tools::Tool.send_http_request(
267-
url,
268-
method: :post,
269-
headers: headers,
270-
body: body,
271-
) do |response|
272-
result[:body] = response.body
273-
result[:status] = response.code.to_i
281+
result
282+
rescue => e
283+
p url
284+
p options
285+
p e
286+
puts e.backtrace
287+
raise e
288+
ensure
289+
self.running_attached_function = false
274290
end
275-
276-
result
277-
ensure
278-
self.running_attached_function = false
279-
end
280-
end,
281-
)
291+
end,
292+
)
293+
end
282294
end
283295
end
284296
end

lib/ai_bot/tools/tool.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ def self.send_http_request(
188188
request = FinalDestination::HTTP::Get.new(uri)
189189
elsif method == :post
190190
request = FinalDestination::HTTP::Post.new(uri)
191+
elsif method == :put
192+
request = FinalDestination::HTTP::Put.new(uri)
193+
elsif method == :patch
194+
request = FinalDestination::HTTP::Patch.new(uri)
195+
elsif method == :delete
196+
request = FinalDestination::HTTP::Delete.new(uri)
191197
end
192198

193199
raise ArgumentError, "Invalid method: #{method}" if !request

lib/completions/anthropic_message_processor.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def to_xml_tool_calls(function_buffer)
3939
)
4040

4141
params = JSON.parse(tool_call.raw_json, symbolize_names: true)
42-
xml = params.map { |name, value| "<#{name}>#{value}</#{name}>" }.join("\n")
42+
xml = params.map { |name, value| "<#{name}>#{CGI.escapeHTML(value)}</#{name}>" }.join("\n")
4343

4444
node.at("tool_name").content = tool_call.name
4545
node.at("tool_id").content = tool_call.id

lib/completions/endpoints/gemini.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def add_to_function_buffer(function_buffer, payload: nil, partial: nil)
179179
if partial[:args]
180180
argument_fragments =
181181
partial[:args].reduce(+"") do |memo, (arg_name, value)|
182-
memo << "\n<#{arg_name}>#{value}</#{arg_name}>"
182+
memo << "\n<#{arg_name}>#{CGI.escapeHTML(value)}</#{arg_name}>"
183183
end
184184
argument_fragments << "\n"
185185

lib/completions/endpoints/open_ai.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def add_to_function_buffer(function_buffer, partial: nil, payload: nil)
173173

174174
argument_fragments =
175175
json_args.reduce(+"") do |memo, (arg_name, value)|
176-
memo << "\n<#{arg_name}>#{value}</#{arg_name}>"
176+
memo << "\n<#{arg_name}>#{CGI.escapeHTML(value)}</#{arg_name}>"
177177
end
178178
argument_fragments << "\n"
179179

spec/lib/completions/endpoints/anthropic_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"y\\": \\"s"} }
7575
7676
event: content_block_delta
77-
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"am"} }
77+
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"<a>m"} }
7878
7979
event: content_block_delta
8080
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" "} }
@@ -118,7 +118,7 @@
118118
<function_calls>
119119
<invoke>
120120
<tool_name>search</tool_name>
121-
<parameters><search_query>sam sam</search_query>
121+
<parameters><search_query>s&lt;a&gt;m sam</search_query>
122122
<category>general</category></parameters>
123123
<tool_id>toolu_01DjrShFRRHp9SnHYRFRc53F</tool_id>
124124
</invoke>

spec/lib/completions/endpoints/gemini_spec.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,34 @@ def tool_response
182182
expect(parsed[:tool_config]).to eq({ function_calling_config: { mode: "AUTO" } })
183183
end
184184

185+
it "properly encodes tool calls" do
186+
prompt = DiscourseAi::Completions::Prompt.new("Hello", tools: [echo_tool])
187+
188+
llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}")
189+
url = "#{model.url}:generateContent?key=123"
190+
191+
response_json = { "functionCall" => { name: "echo", args: { text: "<S>ydney" } } }
192+
response = gemini_mock.response(response_json, tool_call: true).to_json
193+
194+
stub_request(:post, url).to_return(status: 200, body: response)
195+
196+
response = llm.generate(prompt, user: user)
197+
198+
expected = (<<~XML).strip
199+
<function_calls>
200+
<invoke>
201+
<tool_name>echo</tool_name>
202+
<parameters>
203+
<text>&lt;S&gt;ydney</text>
204+
</parameters>
205+
<tool_id>tool_0</tool_id>
206+
</invoke>
207+
</function_calls>
208+
XML
209+
210+
expect(response.strip).to eq(expected)
211+
end
212+
185213
it "Supports Vision API" do
186214
prompt =
187215
DiscourseAi::Completions::Prompt.new(

spec/lib/completions/endpoints/open_ai_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def request_body(prompt, stream: false, tool_call: false)
294294
type: "function",
295295
function: {
296296
name: "echo",
297-
arguments: "{\"text\":\"hello\"}",
297+
arguments: "{\"text\":\"h<e>llo\"}",
298298
},
299299
},
300300
],
@@ -325,7 +325,7 @@ def request_body(prompt, stream: false, tool_call: false)
325325
<invoke>
326326
<tool_name>echo</tool_name>
327327
<parameters>
328-
<text>hello</text>
328+
<text>h&lt;e&gt;llo</text>
329329
</parameters>
330330
<tool_id>call_I8LKnoijVuhKOM85nnEQgWwd</tool_id>
331331
</invoke>

spec/models/ai_tool_spec.rb

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -38,35 +38,37 @@ def create_tool(
3838
expect(runner.invoke).to eq("query" => "test")
3939
end
4040

41-
it "can perform POST HTTP requests" do
42-
script = <<~JS
43-
function invoke(params) {
44-
result = http.post("https://example.com/api",
45-
{
46-
headers: { TestHeader: "TestValue" },
47-
body: JSON.stringify({ data: params.data })
48-
}
49-
);
41+
it "can perform HTTP requests with various verbs" do
42+
%i[post put delete patch].each do |verb|
43+
script = <<~JS
44+
function invoke(params) {
45+
result = http.#{verb}("https://example.com/api",
46+
{
47+
headers: { TestHeader: "TestValue" },
48+
body: JSON.stringify({ data: params.data })
49+
}
50+
);
5051
51-
return result.body;
52-
}
53-
JS
52+
return result.body;
53+
}
54+
JS
5455

55-
tool = create_tool(script: script)
56-
runner = tool.runner({ "data" => "test data" }, llm: nil, bot_user: nil, context: {})
56+
tool = create_tool(script: script)
57+
runner = tool.runner({ "data" => "test data" }, llm: nil, bot_user: nil, context: {})
5758

58-
stub_request(:post, "https://example.com/api").with(
59-
body: "{\"data\":\"test data\"}",
60-
headers: {
61-
"Accept" => "*/*",
62-
"Testheader" => "TestValue",
63-
"User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)",
64-
},
65-
).to_return(status: 200, body: "Success", headers: {})
59+
stub_request(verb, "https://example.com/api").with(
60+
body: "{\"data\":\"test data\"}",
61+
headers: {
62+
"Accept" => "*/*",
63+
"Testheader" => "TestValue",
64+
"User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)",
65+
},
66+
).to_return(status: 200, body: "Success", headers: {})
6667

67-
result = runner.invoke
68+
result = runner.invoke
6869

69-
expect(result).to eq("Success")
70+
expect(result).to eq("Success")
71+
end
7072
end
7173

7274
it "can perform GET HTTP requests, with 1 param" do

0 commit comments

Comments
 (0)