zephira 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +36 -0
- data/Dockerfile +39 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +145 -0
- data/README.md +230 -0
- data/Rakefile +12 -0
- data/exe/zephira +6 -0
- data/lib/zephira/agent/status.rb +20 -0
- data/lib/zephira/agent.rb +312 -0
- data/lib/zephira/backends/open_ai_compatible.rb +74 -0
- data/lib/zephira/backends.rb +17 -0
- data/lib/zephira/cli.rb +41 -0
- data/lib/zephira/commands/about.rb +27 -0
- data/lib/zephira/commands/bye.rb +22 -0
- data/lib/zephira/commands/clear.rb +32 -0
- data/lib/zephira/commands/compact.rb +22 -0
- data/lib/zephira/commands/help.rb +22 -0
- data/lib/zephira/commands/history.rb +25 -0
- data/lib/zephira/commands/model.rb +51 -0
- data/lib/zephira/commands.rb +31 -0
- data/lib/zephira/completions/file_names.rb +22 -0
- data/lib/zephira/completions/slash_commands.rb +17 -0
- data/lib/zephira/completions.rb +22 -0
- data/lib/zephira/config.rb +17 -0
- data/lib/zephira/formatter.rb +68 -0
- data/lib/zephira/history.rb +117 -0
- data/lib/zephira/logger.rb +46 -0
- data/lib/zephira/models/base_model.rb +143 -0
- data/lib/zephira/models/chat_gpt41.rb +15 -0
- data/lib/zephira/models/chat_gpt41_mini.rb +15 -0
- data/lib/zephira/models/claude_35_sonnet.rb +15 -0
- data/lib/zephira/models/gpt_5_4.rb +15 -0
- data/lib/zephira/models/gpt_5_5.rb +15 -0
- data/lib/zephira/models/gpt_o4_mini.rb +15 -0
- data/lib/zephira/models/llama4.rb +15 -0
- data/lib/zephira/models.rb +19 -0
- data/lib/zephira/sandbox.rb +193 -0
- data/lib/zephira/tokens.rb +16 -0
- data/lib/zephira/tools/base_tool.rb +105 -0
- data/lib/zephira/tools/code_search.rb +135 -0
- data/lib/zephira/tools/delete_file.rb +47 -0
- data/lib/zephira/tools/http_request.rb +112 -0
- data/lib/zephira/tools/list_directory.rb +57 -0
- data/lib/zephira/tools/memory_delete.rb +39 -0
- data/lib/zephira/tools/memory_list.rb +37 -0
- data/lib/zephira/tools/memory_read.rb +43 -0
- data/lib/zephira/tools/memory_store.rb +51 -0
- data/lib/zephira/tools/memory_write.rb +39 -0
- data/lib/zephira/tools/read_file.rb +90 -0
- data/lib/zephira/tools/shell.rb +82 -0
- data/lib/zephira/tools/update_file.rb +46 -0
- data/lib/zephira/tools/web_search.rb +113 -0
- data/lib/zephira/tools.rb +70 -0
- data/lib/zephira/version.rb +5 -0
- data/lib/zephira.rb +24 -0
- data/license.txt +21 -0
- data/standard.yml +3 -0
- metadata +243 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Completions
|
|
5
|
+
class FileNames
|
|
6
|
+
def self.complete(input:, agent:)
|
|
7
|
+
return [] unless input.start_with?("@")
|
|
8
|
+
|
|
9
|
+
prefix = input[1..]
|
|
10
|
+
pattern = if prefix.include?("/")
|
|
11
|
+
prefix.end_with?("/") ? "#{prefix}*" : File.join(File.dirname(prefix), "#{File.basename(prefix)}*")
|
|
12
|
+
else
|
|
13
|
+
"#{prefix}*"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Dir.glob(pattern).map do |path|
|
|
17
|
+
"@#{path}#{File.directory?(path) ? "/" : ""}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Completions
|
|
5
|
+
class SlashCommands
|
|
6
|
+
def self.complete(input:, agent:)
|
|
7
|
+
return [] unless input.start_with?("/")
|
|
8
|
+
|
|
9
|
+
prefix = input[1..] || ""
|
|
10
|
+
agent.commands.constants
|
|
11
|
+
.map(&:name)
|
|
12
|
+
.grep(/\A#{Regexp.escape(prefix)}/)
|
|
13
|
+
.map { |cmd| "/#{cmd}" }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Completions
|
|
5
|
+
def self.load(paths:)
|
|
6
|
+
paths.each do |path|
|
|
7
|
+
Dir.glob(File.join(path, "**", "*.rb")).each { |file| require file }
|
|
8
|
+
end
|
|
9
|
+
new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def constants
|
|
13
|
+
@constants ||= ::Zephira::Completions.constants(false).map do |name|
|
|
14
|
+
::Zephira::Completions.const_get(name)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def complete_all(input:, agent:)
|
|
19
|
+
constants.flat_map { |completion| completion.complete(input: input, agent: agent) }.uniq
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Zephira
|
|
6
|
+
class Config
|
|
7
|
+
def self.read(key)
|
|
8
|
+
project_config_path = File.join(Dir.pwd, ".zephira.yml")
|
|
9
|
+
global_config_path = File.expand_path("~/.zephira.yml")
|
|
10
|
+
|
|
11
|
+
project_config = File.exist?(project_config_path) ? (YAML.load_file(project_config_path) || {})[key] : nil
|
|
12
|
+
global_config = File.exist?(global_config_path) ? (YAML.load_file(global_config_path) || {})[key] : nil
|
|
13
|
+
|
|
14
|
+
ENV[key] || project_config || global_config
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Formatter
|
|
5
|
+
FORMAT_STRINGS = {
|
|
6
|
+
color_white: "\e[37m",
|
|
7
|
+
color_red: "\e[31m",
|
|
8
|
+
color_dark_red: "\e[91m",
|
|
9
|
+
color_green: "\e[32m",
|
|
10
|
+
color_grey: "\e[90m",
|
|
11
|
+
format_bold: "\e[1m",
|
|
12
|
+
format_italic: "\e[3m",
|
|
13
|
+
format_underlined: "\e[4m",
|
|
14
|
+
format_strikethrough: "\e[9m",
|
|
15
|
+
format_bold_italic: "\e[1;3m",
|
|
16
|
+
format_bold_underlined: "\e[1;4m",
|
|
17
|
+
format_italic_underlined: "\e[3;4m",
|
|
18
|
+
format_bold_strikethrough: "\e[1;9m",
|
|
19
|
+
format_italic_strikethrough: "\e[3;9m",
|
|
20
|
+
format_underlined_strikethrough: "\e[4;9m",
|
|
21
|
+
format_clear: "\e[0m"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def format(string, indent: 0)
|
|
26
|
+
raise ArgumentError, "Indent must be a non-negative integer" unless indent.is_a?(Integer) && indent >= 0
|
|
27
|
+
|
|
28
|
+
string.each_line.map do |line|
|
|
29
|
+
indented_line = (" " * indent) + line.chomp
|
|
30
|
+
|
|
31
|
+
FORMAT_STRINGS.each do |key, value|
|
|
32
|
+
indented_line.gsub!("###{key.upcase}##", value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# replace any remaining format strings with the clear format
|
|
36
|
+
# this avoids having malformed format strings left in the output
|
|
37
|
+
indented_line.gsub!(/##\w+##/, style(:clear))
|
|
38
|
+
|
|
39
|
+
indented_line
|
|
40
|
+
end.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def available_formats
|
|
44
|
+
FORMAT_STRINGS.except(:format_clear).map do |key, value|
|
|
45
|
+
format_string = "###{key.to_s.upcase}##"
|
|
46
|
+
description = "#{key.to_s.split("_")[1..].join(" ")} text"
|
|
47
|
+
"#{format_string} for #{description}"
|
|
48
|
+
end + ["##FORMAT_CLEAR## to clear all formatting"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def color(color, string = nil)
|
|
52
|
+
color_code = FORMAT_STRINGS[:"color_#{color}"]
|
|
53
|
+
raise ArgumentError, "Invalid color: #{color}" unless color_code
|
|
54
|
+
|
|
55
|
+
return color_code if string.nil?
|
|
56
|
+
"#{color_code}#{string}#{style(:clear)}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def style(style, string = nil)
|
|
60
|
+
style_code = FORMAT_STRINGS[:"format_#{style}"]
|
|
61
|
+
raise ArgumentError, "Invalid style: #{style}" unless style_code
|
|
62
|
+
|
|
63
|
+
return style_code if string.nil?
|
|
64
|
+
"#{style_code}#{string}#{style(:clear)}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Zephira
|
|
8
|
+
class History
|
|
9
|
+
STORAGE_DIR = ".zephira"
|
|
10
|
+
STORAGE_FILE = "history.jsonl"
|
|
11
|
+
COMPACTION_CHUNK_SIZE = 10
|
|
12
|
+
|
|
13
|
+
attr_reader :messages, :session_start
|
|
14
|
+
|
|
15
|
+
def initialize(messages = [])
|
|
16
|
+
@storage_dir = File.join(Dir.pwd, STORAGE_DIR)
|
|
17
|
+
@storage_file = File.join(@storage_dir, STORAGE_FILE)
|
|
18
|
+
|
|
19
|
+
FileUtils.mkdir_p(@storage_dir)
|
|
20
|
+
|
|
21
|
+
if messages.empty? && File.file?(@storage_file) && File.size(@storage_file) > 0
|
|
22
|
+
@messages = load_from_disk
|
|
23
|
+
else
|
|
24
|
+
@messages = messages.dup
|
|
25
|
+
write_all_to_disk
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@session_start = @messages.size
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def append(role:, content:, tool_calls: nil, tool_call_id: nil)
|
|
32
|
+
entry = {
|
|
33
|
+
role: role,
|
|
34
|
+
content: content,
|
|
35
|
+
tool_calls: tool_calls,
|
|
36
|
+
tool_call_id: tool_call_id,
|
|
37
|
+
timestamp: Time.now.iso8601
|
|
38
|
+
}.compact
|
|
39
|
+
@messages << entry
|
|
40
|
+
persist_entry(entry)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def size
|
|
44
|
+
@messages.sum { |message| Tokens.estimate(message[:content]) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def compact(response_model:, api_key:, agent:, token_limit: Float::INFINITY)
|
|
48
|
+
return unless size > token_limit
|
|
49
|
+
|
|
50
|
+
chunks = []
|
|
51
|
+
while size > token_limit && !@messages.empty?
|
|
52
|
+
chunks << @messages.shift(COMPACTION_CHUNK_SIZE)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
chunks.each do |chunk|
|
|
56
|
+
conversation = chunk.map { |message| "#{message[:role]}: #{message[:content]}" }.join("\n")
|
|
57
|
+
summary = response_model.simple_inference(
|
|
58
|
+
api_key: api_key,
|
|
59
|
+
agent: agent,
|
|
60
|
+
messages: [{role: "user", content: "Summarize the following conversation:\n#{conversation}"}]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@messages.unshift(
|
|
64
|
+
role: "system",
|
|
65
|
+
content: "[Summary of #{chunk.size} messages]\n#{summary}",
|
|
66
|
+
timestamp: Time.now.iso8601
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
write_all_to_disk
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def clear
|
|
74
|
+
@messages.clear
|
|
75
|
+
write_all_to_disk
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def clear_session
|
|
79
|
+
return unless @session_start
|
|
80
|
+
@messages = @messages[0...@session_start]
|
|
81
|
+
write_all_to_disk
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def compact_tool_messages!
|
|
85
|
+
@messages = @messages
|
|
86
|
+
.reject { |message| message[:role] == "tool" }
|
|
87
|
+
.map do |message|
|
|
88
|
+
next message unless message[:tool_calls]&.any?
|
|
89
|
+
|
|
90
|
+
summary_lines = message[:tool_calls].map do |tool_call|
|
|
91
|
+
name = tool_call.dig(:function, :name) || tool_call.dig("function", "name")
|
|
92
|
+
arguments = tool_call.dig(:function, :arguments) || tool_call.dig("function", "arguments")
|
|
93
|
+
arguments = JSON.parse(arguments, symbolize_names: true)
|
|
94
|
+
"- `#{name}` with intent `#{arguments[:intent]}`"
|
|
95
|
+
end
|
|
96
|
+
summary_lines.unshift("Agent used tool(s):\n")
|
|
97
|
+
{role: "assistant", content: summary_lines.join("\n"), timestamp: message[:timestamp]}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
write_all_to_disk
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def load_from_disk
|
|
106
|
+
File.readlines(@storage_file).map { |line| JSON.parse(line, symbolize_names: true) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def persist_entry(entry)
|
|
110
|
+
File.open(@storage_file, "a") { |file| file.puts JSON.generate(entry) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def write_all_to_disk
|
|
114
|
+
File.open(@storage_file, "w") { |file| @messages.each { |entry| file.puts JSON.generate(entry) } }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Zephira
|
|
6
|
+
class Logger
|
|
7
|
+
LOG_LEVELS = %i[debug info warn error].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :logfile, :log_level
|
|
10
|
+
|
|
11
|
+
def initialize(file_path:, log_level: :debug)
|
|
12
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
13
|
+
@logfile = File.open(file_path, "a")
|
|
14
|
+
@log_level = log_level
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "Invalid log level: #{log_level}" unless LOG_LEVELS.include?(log_level)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def debug(message, **args)
|
|
20
|
+
log(:debug, message, **args)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def info(message, **args)
|
|
24
|
+
log(:info, message, **args)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def error(message, **args)
|
|
28
|
+
log(:error, message, **args)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def warn(message, **args)
|
|
32
|
+
log(:warn, message, **args)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def log(level, message, **args)
|
|
36
|
+
return unless should_log?(level)
|
|
37
|
+
|
|
38
|
+
@logfile.puts "#{Time.now} - #{level.to_s.strip.upcase} - #{message} - #{args.inspect}"
|
|
39
|
+
@logfile.flush
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def should_log?(level)
|
|
43
|
+
LOG_LEVELS.index(level) >= LOG_LEVELS.index(@log_level)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Zephira
|
|
6
|
+
module Models
|
|
7
|
+
# Base class for all model definitions.
|
|
8
|
+
#
|
|
9
|
+
# To add a new model:
|
|
10
|
+
# 1. Drop a new file in `lib/zephira/models/<name>.rb` — it is auto-loaded.
|
|
11
|
+
# 2. Subclass `BaseModel` and implement `model_name` and `context_limit`.
|
|
12
|
+
# 3. Optionally override `backend` to point at a specific backend class.
|
|
13
|
+
# Defaults to `Backends::OpenAiCompatible` (works for any provider with an
|
|
14
|
+
# OpenAI-compatible API). For provider-specific quirks (Mistral, Anthropic
|
|
15
|
+
# tool-call shape, etc.) define a dedicated backend class and return it
|
|
16
|
+
# from `backend`.
|
|
17
|
+
#
|
|
18
|
+
# `ENV["ZEPHIRA_BACKEND"]` overrides per-model `backend` for debugging.
|
|
19
|
+
class BaseModel
|
|
20
|
+
def self.model_name
|
|
21
|
+
raise NotImplementedError, "You must implement the model_name method"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.context_limit
|
|
25
|
+
raise NotImplementedError, "You must implement the context_limit method"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Override in subclasses to bind a model to a specific backend class.
|
|
29
|
+
def self.backend
|
|
30
|
+
Zephira::Backends::OpenAiCompatible
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.backend_class
|
|
34
|
+
identifier = ENV["ZEPHIRA_BACKEND"]
|
|
35
|
+
if identifier
|
|
36
|
+
found = Zephira::Backends.find_by_name(identifier)
|
|
37
|
+
return found if found
|
|
38
|
+
end
|
|
39
|
+
backend
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.format_tools(tools)
|
|
43
|
+
tools.to_h.map do |tool|
|
|
44
|
+
{
|
|
45
|
+
type: "function",
|
|
46
|
+
function: {
|
|
47
|
+
name: tool[:name],
|
|
48
|
+
description: tool[:description],
|
|
49
|
+
parameters: tool[:parameters]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.inference(api_key:, agent:, messages: [], base_url: nil)
|
|
56
|
+
client = backend_class.new(api_key: api_key, base_url: base_url)
|
|
57
|
+
|
|
58
|
+
loop do
|
|
59
|
+
agent.thinking(self)
|
|
60
|
+
response = client.chat(
|
|
61
|
+
model_name: model_name,
|
|
62
|
+
messages: messages,
|
|
63
|
+
agent: agent,
|
|
64
|
+
tools: format_tools(agent.tools)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
tool_calls = Array(response["tool_calls"]).select { |tool_call| tool_call["type"] == "function" }
|
|
68
|
+
|
|
69
|
+
if tool_calls.empty?
|
|
70
|
+
content = response["content"]
|
|
71
|
+
return (content.nil? || content.empty?) ? nil : content
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
messages << {role: "assistant", content: response["content"], tool_calls: response["tool_calls"]}
|
|
75
|
+
agent.history.append(role: "assistant", content: response["content"], tool_calls: response["tool_calls"])
|
|
76
|
+
|
|
77
|
+
dispatch_tool_calls(tool_calls, agent: agent).each do |call, content|
|
|
78
|
+
messages << {role: "tool", tool_call_id: call["id"], content: content}
|
|
79
|
+
agent.history.append(role: "tool", tool_call_id: call["id"], content: content)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns an array of [call, content] pairs in the original order. Read-only
|
|
85
|
+
# tools are run concurrently via threads (network/disk I/O releases the GVL);
|
|
86
|
+
# mutating tools run sequentially after, in original order.
|
|
87
|
+
def self.dispatch_tool_calls(tool_calls, agent:)
|
|
88
|
+
results = Array.new(tool_calls.size)
|
|
89
|
+
|
|
90
|
+
read_only_calls = []
|
|
91
|
+
mutating_calls = []
|
|
92
|
+
tool_calls.each_with_index do |call, index|
|
|
93
|
+
if agent.tools.read_only?(call["function"]["name"])
|
|
94
|
+
read_only_calls << [index, call]
|
|
95
|
+
else
|
|
96
|
+
mutating_calls << [index, call]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
threads = read_only_calls.map do |index, call|
|
|
101
|
+
Thread.new do
|
|
102
|
+
args = parse_tool_arguments(call, agent: agent)
|
|
103
|
+
result = agent.run_tool(name: call["function"]["name"], args: args)
|
|
104
|
+
results[index] = [call, serialize_tool_result(result)]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
threads.each(&:join)
|
|
108
|
+
|
|
109
|
+
mutating_calls.each do |index, call|
|
|
110
|
+
args = parse_tool_arguments(call, agent: agent)
|
|
111
|
+
result = agent.run_tool(name: call["function"]["name"], args: args)
|
|
112
|
+
results[index] = [call, serialize_tool_result(result)]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
results
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.simple_inference(api_key:, messages:, agent: nil, base_url: nil)
|
|
119
|
+
client = backend_class.new(api_key: api_key, base_url: base_url)
|
|
120
|
+
agent.thinking(self) if agent.respond_to?(:thinking)
|
|
121
|
+
client.chat(model_name: model_name, messages: messages, agent: agent)["content"]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.parse_tool_arguments(call, agent:)
|
|
125
|
+
raw = call["function"]["arguments"] || "{}"
|
|
126
|
+
JSON.parse(raw, symbolize_names: true)
|
|
127
|
+
rescue JSON::ParserError => exception
|
|
128
|
+
agent&.logger&.error("Failed to parse tool arguments for #{call["function"]["name"]}: #{exception.message}. Raw: #{raw.inspect}")
|
|
129
|
+
{}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.serialize_tool_result(result)
|
|
133
|
+
return result unless result.is_a?(Hash) && result.key?(:outcome)
|
|
134
|
+
|
|
135
|
+
if result[:outcome] == "success"
|
|
136
|
+
result[:data].is_a?(String) ? result[:data] : JSON.pretty_generate([result[:data]])
|
|
137
|
+
else
|
|
138
|
+
result[:error].to_s
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
module Models
|
|
5
|
+
require_relative "models/base_model"
|
|
6
|
+
Dir[File.join(__dir__, "models", "*.rb")].each { |file| require file }
|
|
7
|
+
|
|
8
|
+
def self.available
|
|
9
|
+
constants(false)
|
|
10
|
+
.map { |const| const_get(const) }
|
|
11
|
+
.reject { |const| const == BaseModel }
|
|
12
|
+
.select { |const| const.respond_to?(:model_name) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.find_by_name(name)
|
|
16
|
+
available.find { |model| model.model_name.casecmp(name.to_s).zero? }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|