Skip to content

Commit 7884ff9

Browse files
Feat Support image URLs in tool outputs for Langchain::Assistant (#894)
* Feat handle image_url from tools output * Update assistant.rb * better handling of image_url * add specs * add ToolResponse class helper and use it * Add helpers for response * update response type * Update tool_response.rb * update specs * Update assistant.rb * Update assistant_spec.rb * Update assistant.rb * reset llm provider changes * reset default llm provider specs * clear and add helper documentation * Update assistant_spec.rb * Update assistant_spec.rb * Merge Langchain::ToolHelpers module into Langchain::ToolDefinition * Fixing specs * CHANGELOG + README entries --------- Co-authored-by: Andrei Bondarev <[email protected]>
1 parent ca95d11 commit 7884ff9

26 files changed

+269
-82
lines changed

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
ruby 3.3.0
1+
ruby 3.4

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- [SECURITY]: A change which fixes a security vulnerability.
1111

1212
## [Unreleased]
13+
- [BREAKING] [https://github.com/patterns-ai-core/langchainrb/pull/894] Tools can now output image_urls, and all tool output must be wrapped by a tool_response() method
1314

1415
## [0.19.3] - 2025-01-13
1516
- [BUGFIX] [https://github.com/patterns-ai-core/langchainrb/pull/900] Empty text content should not be set when content is nil when using AnthropicMessage

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -580,11 +580,11 @@ class MovieInfoTool
580580
end
581581

582582
def search_movie(query:)
583-
...
583+
tool_response(...)
584584
end
585585

586586
def get_movie_details(movie_id:)
587-
...
587+
tool_response(...)
588588
end
589589
end
590590
```

lib/langchain/assistant.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,9 +371,15 @@ def run_tool(tool_call)
371371

372372
# Call the callback if set
373373
tool_execution_callback.call(tool_call_id, tool_name, method_name, tool_arguments) if tool_execution_callback # rubocop:disable Style/SafeNavigation
374+
374375
output = tool_instance.send(method_name, **tool_arguments)
375376

376-
submit_tool_output(tool_call_id: tool_call_id, output: output)
377+
# Handle both ToolResponse and legacy return values
378+
if output.is_a?(ToolResponse)
379+
add_message(role: @llm_adapter.tool_role, content: output.content, image_url: output.image_url, tool_call_id: tool_call_id)
380+
else
381+
submit_tool_output(tool_call_id: tool_call_id, output: output)
382+
end
377383
end
378384

379385
# Build a message

lib/langchain/tool/calculator.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ def initialize
2626
# Evaluates a pure math expression or if equation contains non-math characters (e.g.: "12F in Celsius") then it uses the google search calculator to evaluate the expression
2727
#
2828
# @param input [String] math expression
29-
# @return [String] Answer
29+
# @return [Langchain::Tool::Response] Answer
3030
def execute(input:)
3131
Langchain.logger.debug("#{self.class} - Executing \"#{input}\"")
3232

33-
Eqn::Calculator.calc(input)
33+
result = Eqn::Calculator.calc(input)
34+
tool_response(content: result)
3435
rescue Eqn::ParseError, Eqn::NoVariableValueError
35-
"\"#{input}\" is an invalid mathematical expression"
36+
tool_response(content: "\"#{input}\" is an invalid mathematical expression")
3637
end
3738
end
3839
end

lib/langchain/tool/database.rb

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,58 +49,61 @@ def initialize(connection_string:, tables: [], exclude_tables: [])
4949

5050
# Database Tool: Returns a list of tables in the database
5151
#
52-
# @return [Array<Symbol>] List of tables in the database
52+
# @return [Langchain::Tool::Response] List of tables in the database
5353
def list_tables
54-
db.tables
54+
tool_response(content: db.tables)
5555
end
5656

5757
# Database Tool: Returns the schema for a list of tables
5858
#
5959
# @param tables [Array<String>] The tables to describe.
60-
# @return [String] The schema for the tables
60+
# @return [Langchain::Tool::Response] The schema for the tables
6161
def describe_tables(tables: [])
6262
return "No tables specified" if tables.empty?
6363

6464
Langchain.logger.debug("#{self.class} - Describing tables: #{tables}")
6565

66-
tables
66+
result = tables
6767
.map do |table|
6868
describe_table(table)
6969
end
7070
.join("\n")
71+
72+
tool_response(content: result)
7173
end
7274

7375
# Database Tool: Returns the database schema
7476
#
75-
# @return [String] Database schema
77+
# @return [Langchain::Tool::Response] Database schema
7678
def dump_schema
7779
Langchain.logger.debug("#{self.class} - Dumping schema tables and keys")
7880

7981
schemas = db.tables.map do |table|
8082
describe_table(table)
8183
end
82-
schemas.join("\n")
84+
85+
tool_response(content: schemas.join("\n"))
8386
end
8487

8588
# Database Tool: Executes a SQL query and returns the results
8689
#
8790
# @param input [String] SQL query to be executed
88-
# @return [Array] Results from the SQL query
91+
# @return [Langchain::Tool::Response] Results from the SQL query
8992
def execute(input:)
9093
Langchain.logger.debug("#{self.class} - Executing \"#{input}\"")
9194

92-
db[input].to_a
95+
tool_response(content: db[input].to_a)
9396
rescue Sequel::DatabaseError => e
9497
Langchain.logger.error("#{self.class} - #{e.message}")
95-
e.message # Return error to LLM
98+
tool_response(content: e.message)
9699
end
97100

98101
private
99102

100103
# Describes a table and its schema
101104
#
102105
# @param table [String] The table to describe
103-
# @return [String] The schema for the table
106+
# @return [Langchain::Tool::Response] The schema for the table
104107
def describe_table(table)
105108
# TODO: There's probably a clear way to do all of this below
106109

@@ -127,6 +130,8 @@ def describe_table(table)
127130
schema << ",\n" unless fk == db.foreign_key_list(table).last
128131
end
129132
schema << ");\n"
133+
134+
tool_response(content: schema)
130135
end
131136
end
132137
end

lib/langchain/tool/file_system.rb

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,22 @@ class FileSystem
2424
end
2525

2626
def list_directory(directory_path:)
27-
Dir.entries(directory_path)
27+
tool_response(content: Dir.entries(directory_path))
2828
rescue Errno::ENOENT
29-
"No such directory: #{directory_path}"
29+
tool_response(content: "No such directory: #{directory_path}")
3030
end
3131

3232
def read_file(file_path:)
33-
File.read(file_path)
33+
tool_response(content: File.read(file_path))
3434
rescue Errno::ENOENT
35-
"No such file: #{file_path}"
35+
tool_response(content: "No such file: #{file_path}")
3636
end
3737

3838
def write_to_file(file_path:, content:)
3939
File.write(file_path, content)
40+
tool_response(content: "File written successfully")
4041
rescue Errno::EACCES
41-
"Permission denied: #{file_path}"
42+
tool_response(content: "Permission denied: #{file_path}")
4243
end
4344
end
4445
end

lib/langchain/tool/google_search.rb

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,39 +36,39 @@ def initialize(api_key:)
3636
# Executes Google Search and returns the result
3737
#
3838
# @param input [String] search query
39-
# @return [String] Answer
39+
# @return [Langchain::Tool::Response] Answer
4040
def execute(input:)
4141
Langchain.logger.debug("#{self.class} - Executing \"#{input}\"")
4242

4343
results = execute_search(input: input)
4444

4545
answer_box = results[:answer_box_list] ? results[:answer_box_list].first : results[:answer_box]
4646
if answer_box
47-
return answer_box[:result] ||
47+
return tool_response(content: answer_box[:result] ||
4848
answer_box[:answer] ||
4949
answer_box[:snippet] ||
5050
answer_box[:snippet_highlighted_words] ||
51-
answer_box.reject { |_k, v| v.is_a?(Hash) || v.is_a?(Array) || v.start_with?("http") }
51+
answer_box.reject { |_k, v| v.is_a?(Hash) || v.is_a?(Array) || v.start_with?("http") })
5252
elsif (events_results = results[:events_results])
53-
return events_results.take(10)
53+
return tool_response(content: events_results.take(10))
5454
elsif (sports_results = results[:sports_results])
55-
return sports_results
55+
return tool_response(content: sports_results)
5656
elsif (top_stories = results[:top_stories])
57-
return top_stories
57+
return tool_response(content: top_stories)
5858
elsif (news_results = results[:news_results])
59-
return news_results
59+
return tool_response(content: news_results)
6060
elsif (jobs_results = results.dig(:jobs_results, :jobs))
61-
return jobs_results
61+
return tool_response(content: jobs_results)
6262
elsif (shopping_results = results[:shopping_results]) && shopping_results.first.key?(:title)
63-
return shopping_results.take(3)
63+
return tool_response(content: shopping_results.take(3))
6464
elsif (questions_and_answers = results[:questions_and_answers])
65-
return questions_and_answers
65+
return tool_response(content: questions_and_answers)
6666
elsif (popular_destinations = results.dig(:popular_destinations, :destinations))
67-
return popular_destinations
67+
return tool_response(content: popular_destinations)
6868
elsif (top_sights = results.dig(:top_sights, :sights))
69-
return top_sights
69+
return tool_response(content: top_sights)
7070
elsif (images_results = results[:images_results]) && images_results.first.key?(:thumbnail)
71-
return images_results.map { |h| h[:thumbnail] }.take(10)
71+
return tool_response(content: images_results.map { |h| h[:thumbnail] }.take(10))
7272
end
7373

7474
snippets = []
@@ -110,8 +110,8 @@ def execute(input:)
110110
snippets << local_results
111111
end
112112

113-
return "No good search result found" if snippets.empty?
114-
snippets
113+
return tool_response(content: "No good search result found") if snippets.empty?
114+
tool_response(content: snippets)
115115
end
116116

117117
#

lib/langchain/tool/news_retriever.rb

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def initialize(api_key: ENV["NEWS_API_KEY"])
5757
# @param page_size [Integer] The number of results to return per page. 20 is the API's default, 100 is the maximum. Our default is 5.
5858
# @param page [Integer] Use this to page through the results.
5959
#
60-
# @return [String] JSON response
60+
# @return [Langchain::Tool::Response] JSON response
6161
def get_everything(
6262
q: nil,
6363
search_in: nil,
@@ -86,7 +86,8 @@ def get_everything(
8686
params[:pageSize] = page_size if page_size
8787
params[:page] = page if page
8888

89-
send_request(path: "everything", params: params)
89+
response = send_request(path: "everything", params: params)
90+
tool_response(content: response)
9091
end
9192

9293
# Retrieve top headlines
@@ -98,7 +99,7 @@ def get_everything(
9899
# @param page_size [Integer] The number of results to return per page. 20 is the API's default, 100 is the maximum. Our default is 5.
99100
# @param page [Integer] Use this to page through the results.
100101
#
101-
# @return [String] JSON response
102+
# @return [Langchain::Tool::Response] JSON response
102103
def get_top_headlines(
103104
country: nil,
104105
category: nil,
@@ -117,7 +118,8 @@ def get_top_headlines(
117118
params[:pageSize] = page_size if page_size
118119
params[:page] = page if page
119120

120-
send_request(path: "top-headlines", params: params)
121+
response = send_request(path: "top-headlines", params: params)
122+
tool_response(content: response)
121123
end
122124

123125
# Retrieve news sources
@@ -126,7 +128,7 @@ def get_top_headlines(
126128
# @param language [String] The 2-letter ISO-639-1 code of the language you want to get headlines for. Possible options: ar, de, en, es, fr, he, it, nl, no, pt, ru, se, ud, zh.
127129
# @param country [String] The 2-letter ISO 3166-1 code of the country you want to get headlines for. Possible options: ae, ar, at, au, be, bg, br, ca, ch, cn, co, cu, cz, de, eg, fr, gb, gr, hk, hu, id, ie, il, in, it, jp, kr, lt, lv, ma, mx, my, ng, nl, no, nz, ph, pl, pt, ro, rs, ru, sa, se, sg, si, sk, th, tr, tw, ua, us, ve, za.
128130
#
129-
# @return [String] JSON response
131+
# @return [Langchain::Tool::Response] JSON response
130132
def get_sources(
131133
category: nil,
132134
language: nil,
@@ -139,7 +141,8 @@ def get_sources(
139141
params[:category] = category if category
140142
params[:language] = language if language
141143

142-
send_request(path: "top-headlines/sources", params: params)
144+
response = send_request(path: "top-headlines/sources", params: params)
145+
tool_response(content: response)
143146
end
144147

145148
private

lib/langchain/tool/ruby_code_interpreter.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ def initialize(timeout: 30)
2727
# Executes Ruby code in a sandboxes environment.
2828
#
2929
# @param input [String] ruby code expression
30-
# @return [String] Answer
30+
# @return [Langchain::Tool::Response] Answer
3131
def execute(input:)
3232
Langchain.logger.debug("#{self.class} - Executing \"#{input}\"")
3333

34-
safe_eval(input)
34+
tool_response(content: safe_eval(input))
3535
end
3636

3737
def safe_eval(code)

0 commit comments

Comments
 (0)