elelem 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +74 -11
- data/Rakefile +1 -3
- data/lib/elelem/agent.rb +247 -31
- data/lib/elelem/application.rb +8 -28
- data/lib/elelem/conversation.rb +17 -2
- data/lib/elelem/system_prompt.erb +4 -17
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +3 -17
- metadata +1 -36
- data/lib/elelem/api.rb +0 -48
- data/lib/elelem/configuration.rb +0 -84
- data/lib/elelem/mcp_client.rb +0 -136
- data/lib/elelem/states/idle.rb +0 -23
- data/lib/elelem/states/working/error.rb +0 -19
- data/lib/elelem/states/working/executing.rb +0 -19
- data/lib/elelem/states/working/state.rb +0 -26
- data/lib/elelem/states/working/talking.rb +0 -19
- data/lib/elelem/states/working/thinking.rb +0 -18
- data/lib/elelem/states/working/waiting.rb +0 -29
- data/lib/elelem/states/working.rb +0 -55
- data/lib/elelem/tool.rb +0 -32
- data/lib/elelem/toolbox/exec.rb +0 -61
- data/lib/elelem/toolbox/file.rb +0 -66
- data/lib/elelem/toolbox/mcp.rb +0 -37
- data/lib/elelem/toolbox/memory.rb +0 -164
- data/lib/elelem/toolbox/prompt.rb +0 -25
- data/lib/elelem/toolbox/web.rb +0 -126
- data/lib/elelem/toolbox.rb +0 -8
- data/lib/elelem/tools.rb +0 -41
- data/lib/elelem/tui.rb +0 -66
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: elelem
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mo khan
|
|
@@ -9,20 +9,6 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: cli-ui
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - ">="
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0'
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - ">="
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0'
|
|
26
12
|
- !ruby/object:Gem::Dependency
|
|
27
13
|
name: erb
|
|
28
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -164,30 +150,9 @@ files:
|
|
|
164
150
|
- exe/elelem
|
|
165
151
|
- lib/elelem.rb
|
|
166
152
|
- lib/elelem/agent.rb
|
|
167
|
-
- lib/elelem/api.rb
|
|
168
153
|
- lib/elelem/application.rb
|
|
169
|
-
- lib/elelem/configuration.rb
|
|
170
154
|
- lib/elelem/conversation.rb
|
|
171
|
-
- lib/elelem/mcp_client.rb
|
|
172
|
-
- lib/elelem/states/idle.rb
|
|
173
|
-
- lib/elelem/states/working.rb
|
|
174
|
-
- lib/elelem/states/working/error.rb
|
|
175
|
-
- lib/elelem/states/working/executing.rb
|
|
176
|
-
- lib/elelem/states/working/state.rb
|
|
177
|
-
- lib/elelem/states/working/talking.rb
|
|
178
|
-
- lib/elelem/states/working/thinking.rb
|
|
179
|
-
- lib/elelem/states/working/waiting.rb
|
|
180
155
|
- lib/elelem/system_prompt.erb
|
|
181
|
-
- lib/elelem/tool.rb
|
|
182
|
-
- lib/elelem/toolbox.rb
|
|
183
|
-
- lib/elelem/toolbox/exec.rb
|
|
184
|
-
- lib/elelem/toolbox/file.rb
|
|
185
|
-
- lib/elelem/toolbox/mcp.rb
|
|
186
|
-
- lib/elelem/toolbox/memory.rb
|
|
187
|
-
- lib/elelem/toolbox/prompt.rb
|
|
188
|
-
- lib/elelem/toolbox/web.rb
|
|
189
|
-
- lib/elelem/tools.rb
|
|
190
|
-
- lib/elelem/tui.rb
|
|
191
156
|
- lib/elelem/version.rb
|
|
192
157
|
homepage: https://www.mokhan.ca
|
|
193
158
|
licenses:
|
data/lib/elelem/api.rb
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "net/llm"
|
|
4
|
-
|
|
5
|
-
module Elelem
|
|
6
|
-
class Api
|
|
7
|
-
attr_reader :configuration, :client
|
|
8
|
-
|
|
9
|
-
def initialize(configuration)
|
|
10
|
-
@configuration = configuration
|
|
11
|
-
@client = Net::Llm::Ollama.new(
|
|
12
|
-
host: configuration.host,
|
|
13
|
-
model: configuration.model
|
|
14
|
-
)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def chat(messages, &block)
|
|
18
|
-
tools = configuration.tools.to_h
|
|
19
|
-
client.chat(messages, tools) do |chunk|
|
|
20
|
-
normalized = normalize_ollama_response(chunk)
|
|
21
|
-
block.call(normalized) if normalized
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
def normalize_ollama_response(chunk)
|
|
28
|
-
return done_response(chunk) if chunk["done"]
|
|
29
|
-
|
|
30
|
-
normalize_message(chunk["message"])
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def done_response(chunk)
|
|
34
|
-
{ "done" => true, "finish_reason" => chunk["done_reason"] || "stop" }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def normalize_message(message)
|
|
38
|
-
return nil unless message
|
|
39
|
-
|
|
40
|
-
{}.tap do |result|
|
|
41
|
-
result["role"] = message["role"] if message["role"]
|
|
42
|
-
result["content"] = message["content"] if message["content"]
|
|
43
|
-
result["reasoning"] = message["thinking"] if message["thinking"]
|
|
44
|
-
result["tool_calls"] = message["tool_calls"] if message["tool_calls"]
|
|
45
|
-
end.then { |r| r.empty? ? nil : r }
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
data/lib/elelem/configuration.rb
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
class Configuration
|
|
5
|
-
attr_reader :host, :model, :token, :debug
|
|
6
|
-
|
|
7
|
-
def initialize(host:, model:, token:, debug: false)
|
|
8
|
-
@host = host
|
|
9
|
-
@model = model
|
|
10
|
-
@token = token
|
|
11
|
-
@debug = debug
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def tui
|
|
15
|
-
@tui ||= TUI.new($stdin, $stdout)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def api
|
|
19
|
-
@api ||= Api.new(self)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def logger
|
|
23
|
-
@logger ||= Logger.new("#{Time.now.strftime("%Y-%m-%d")}-elelem.log").tap do |logger|
|
|
24
|
-
if debug
|
|
25
|
-
logger.level = :debug
|
|
26
|
-
else
|
|
27
|
-
logger.level = ENV.fetch("LOG_LEVEL", "warn")
|
|
28
|
-
end
|
|
29
|
-
logger.formatter = ->(severity, datetime, progname, message) {
|
|
30
|
-
timestamp = datetime.strftime("%H:%M:%S.%3N")
|
|
31
|
-
"[#{timestamp}] #{severity.ljust(5)} #{message.to_s.strip}\n"
|
|
32
|
-
}
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def conversation
|
|
37
|
-
@conversation ||= Conversation.new.tap do |conversation|
|
|
38
|
-
resources = mcp_clients.map do |client|
|
|
39
|
-
client.resources.map do |resource|
|
|
40
|
-
resource["uri"]
|
|
41
|
-
end
|
|
42
|
-
end.flatten
|
|
43
|
-
conversation.add(role: :tool, content: resources)
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def tools
|
|
48
|
-
@tools ||= Tools.new(self,
|
|
49
|
-
[
|
|
50
|
-
Toolbox::Exec.new(self),
|
|
51
|
-
Toolbox::File.new(self),
|
|
52
|
-
Toolbox::Web.new(self),
|
|
53
|
-
Toolbox::Prompt.new(self),
|
|
54
|
-
Toolbox::Memory.new(self),
|
|
55
|
-
] + mcp_tools
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def cleanup
|
|
60
|
-
@mcp_clients&.each(&:shutdown)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
private
|
|
64
|
-
|
|
65
|
-
def mcp_tools
|
|
66
|
-
@mcp_tools ||= mcp_clients.map do |client|
|
|
67
|
-
client.tools.map do |tool|
|
|
68
|
-
Toolbox::MCP.new(client, tui, tool)
|
|
69
|
-
end
|
|
70
|
-
end.flatten
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def mcp_clients
|
|
74
|
-
@mcp_clients ||= begin
|
|
75
|
-
config = Pathname.pwd.join(".mcp.json")
|
|
76
|
-
return [] unless config.exist?
|
|
77
|
-
|
|
78
|
-
JSON.parse(config.read).map do |_key, value|
|
|
79
|
-
MCPClient.new(self, [value["command"]] + value["args"])
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
data/lib/elelem/mcp_client.rb
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
class MCPClient
|
|
5
|
-
attr_reader :tools, :resources
|
|
6
|
-
|
|
7
|
-
def initialize(configuration, command = [])
|
|
8
|
-
@configuration = configuration
|
|
9
|
-
@stdin, @stdout, @stderr, @worker = Open3.popen3(*command, pgroup: true)
|
|
10
|
-
|
|
11
|
-
# 1. Send initialize request
|
|
12
|
-
send_request(
|
|
13
|
-
method: "initialize",
|
|
14
|
-
params: {
|
|
15
|
-
protocolVersion: "2025-06-08",
|
|
16
|
-
capabilities: {
|
|
17
|
-
tools: {}
|
|
18
|
-
},
|
|
19
|
-
clientInfo: {
|
|
20
|
-
name: "Elelem",
|
|
21
|
-
version: Elelem::VERSION
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
# 2. Send initialized notification (optional for some MCP servers)
|
|
27
|
-
send_notification(method: "notifications/initialized")
|
|
28
|
-
|
|
29
|
-
# 3. Now we can request tools
|
|
30
|
-
@tools = send_request(method: "tools/list")&.dig("tools") || []
|
|
31
|
-
@resources = send_request(method: "resources/list")&.dig("resources") || []
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def connected?
|
|
35
|
-
return false unless @worker&.alive?
|
|
36
|
-
return false unless @stdin && !@stdin.closed?
|
|
37
|
-
return false unless @stdout && !@stdout.closed?
|
|
38
|
-
|
|
39
|
-
begin
|
|
40
|
-
Process.getpgid(@worker.pid)
|
|
41
|
-
true
|
|
42
|
-
rescue Errno::ESRCH
|
|
43
|
-
false
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def call(name, arguments = {})
|
|
48
|
-
send_request(
|
|
49
|
-
method: "tools/call",
|
|
50
|
-
params: {
|
|
51
|
-
name: name,
|
|
52
|
-
arguments: arguments
|
|
53
|
-
}
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def shutdown
|
|
58
|
-
return unless connected?
|
|
59
|
-
|
|
60
|
-
configuration.logger.debug("Shutting down MCP client")
|
|
61
|
-
|
|
62
|
-
[@stdin, @stdout, @stderr].each do |stream|
|
|
63
|
-
stream&.close unless stream&.closed?
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
return unless @worker&.alive?
|
|
67
|
-
|
|
68
|
-
begin
|
|
69
|
-
Process.kill("TERM", @worker.pid)
|
|
70
|
-
# Give it 2 seconds to terminate gracefully
|
|
71
|
-
Timeout.timeout(2) { @worker.value }
|
|
72
|
-
rescue Timeout::Error
|
|
73
|
-
# Force kill if it doesn't respond
|
|
74
|
-
begin
|
|
75
|
-
Process.kill("KILL", @worker.pid)
|
|
76
|
-
rescue StandardError
|
|
77
|
-
nil
|
|
78
|
-
end
|
|
79
|
-
rescue Errno::ESRCH
|
|
80
|
-
# Process already dead
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
private
|
|
85
|
-
|
|
86
|
-
attr_reader :stdin, :stdout, :stderr, :worker, :configuration
|
|
87
|
-
|
|
88
|
-
def send_request(method:, params: {})
|
|
89
|
-
return {} unless connected?
|
|
90
|
-
|
|
91
|
-
request = {
|
|
92
|
-
jsonrpc: "2.0",
|
|
93
|
-
id: Time.now.to_i,
|
|
94
|
-
method: method
|
|
95
|
-
}
|
|
96
|
-
request[:params] = params unless params.empty?
|
|
97
|
-
configuration.logger.debug(JSON.pretty_generate(request))
|
|
98
|
-
|
|
99
|
-
@stdin.puts(JSON.generate(request))
|
|
100
|
-
@stdin.flush
|
|
101
|
-
|
|
102
|
-
response_line = @stdout.gets&.strip
|
|
103
|
-
return {} if response_line.nil? || response_line.empty?
|
|
104
|
-
|
|
105
|
-
response = JSON.parse(response_line)
|
|
106
|
-
configuration.logger.debug(JSON.pretty_generate(response))
|
|
107
|
-
|
|
108
|
-
if response["error"]
|
|
109
|
-
configuration.logger.error(response["error"]["message"])
|
|
110
|
-
{ error: response["error"]["message"] }
|
|
111
|
-
else
|
|
112
|
-
response["result"]
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def send_notification(method:, params: {})
|
|
117
|
-
return unless connected?
|
|
118
|
-
|
|
119
|
-
notification = {
|
|
120
|
-
jsonrpc: "2.0",
|
|
121
|
-
method: method
|
|
122
|
-
}
|
|
123
|
-
notification[:params] = params unless params.empty?
|
|
124
|
-
configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}")
|
|
125
|
-
@stdin.puts(JSON.generate(notification))
|
|
126
|
-
@stdin.flush
|
|
127
|
-
|
|
128
|
-
response_line = @stdout.gets&.strip
|
|
129
|
-
return {} if response_line.nil? || response_line.empty?
|
|
130
|
-
|
|
131
|
-
response = JSON.parse(response_line)
|
|
132
|
-
configuration.logger.debug(JSON.pretty_generate(response))
|
|
133
|
-
response
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
end
|
data/lib/elelem/states/idle.rb
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module States
|
|
5
|
-
class Idle
|
|
6
|
-
def run(agent)
|
|
7
|
-
agent.logger.debug("Idling...")
|
|
8
|
-
agent.tui.say("#{Dir.pwd} (#{agent.model}) [#{git_branch}]", colour: :magenta, newline: true)
|
|
9
|
-
input = agent.tui.prompt("モ ")
|
|
10
|
-
agent.quit if input.nil? || input.empty? || input == "exit" || input == "quit"
|
|
11
|
-
|
|
12
|
-
agent.conversation.add(role: :user, content: input)
|
|
13
|
-
agent.transition_to(Working)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
def git_branch
|
|
19
|
-
`git branch --no-color --show-current --no-abbrev`.strip
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module States
|
|
5
|
-
module Working
|
|
6
|
-
class Error < State
|
|
7
|
-
def initialize(agent, error_message)
|
|
8
|
-
super(agent, "X", :red)
|
|
9
|
-
@error_message = error_message
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def process(_message)
|
|
13
|
-
agent.tui.say("\nTool execution failed: #{@error_message}", colour: :red)
|
|
14
|
-
Waiting.new(agent)
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module States
|
|
5
|
-
module Working
|
|
6
|
-
class Executing < State
|
|
7
|
-
def process(message)
|
|
8
|
-
if message["tool_calls"]&.any?
|
|
9
|
-
message["tool_calls"].each do |tool_call|
|
|
10
|
-
agent.conversation.add(role: :tool, content: agent.execute(tool_call))
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
Thinking.new(agent, "*", :yellow)
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module States
|
|
5
|
-
module Working
|
|
6
|
-
class State
|
|
7
|
-
attr_reader :agent
|
|
8
|
-
|
|
9
|
-
def initialize(agent, icon, colour)
|
|
10
|
-
@agent = agent
|
|
11
|
-
|
|
12
|
-
agent.logger.debug("#{display_name}...")
|
|
13
|
-
agent.tui.show_progress("#{display_name}...", icon, colour: colour)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def run(message)
|
|
17
|
-
process(message)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def display_name
|
|
21
|
-
self.class.name.split("::").last
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module States
|
|
5
|
-
module Working
|
|
6
|
-
class Talking < State
|
|
7
|
-
def process(message)
|
|
8
|
-
if message["content"] && !message["content"]&.empty?
|
|
9
|
-
agent.conversation.add(role: message["role"], content: message["content"])
|
|
10
|
-
agent.tui.say(message["content"], colour: :default, newline: false)
|
|
11
|
-
self
|
|
12
|
-
else
|
|
13
|
-
Waiting.new(agent).process(message)
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module States
|
|
5
|
-
module Working
|
|
6
|
-
class Thinking < State
|
|
7
|
-
def process(message)
|
|
8
|
-
if message["reasoning"] && !message["reasoning"]&.empty?
|
|
9
|
-
agent.tui.say(message["reasoning"], colour: :gray, newline: false)
|
|
10
|
-
self
|
|
11
|
-
else
|
|
12
|
-
Waiting.new(agent).process(message)
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module States
|
|
5
|
-
module Working
|
|
6
|
-
class Waiting < State
|
|
7
|
-
def initialize(agent)
|
|
8
|
-
super(agent, ".", :cyan)
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def process(message)
|
|
12
|
-
state_for(message)&.process(message) || self
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
private
|
|
16
|
-
|
|
17
|
-
def state_for(message)
|
|
18
|
-
if message["reasoning"] && !message["reasoning"].empty?
|
|
19
|
-
Thinking.new(agent, "*", :yellow)
|
|
20
|
-
elsif message["tool_calls"]&.any?
|
|
21
|
-
Executing.new(agent, ">", :magenta)
|
|
22
|
-
elsif message["content"] && !message["content"].empty?
|
|
23
|
-
Talking.new(agent, "~", :white)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module States
|
|
5
|
-
module Working
|
|
6
|
-
class << self
|
|
7
|
-
def run(agent)
|
|
8
|
-
state = Waiting.new(agent)
|
|
9
|
-
|
|
10
|
-
loop do
|
|
11
|
-
streaming_done = false
|
|
12
|
-
finish_reason = nil
|
|
13
|
-
|
|
14
|
-
agent.api.chat(agent.conversation.history) do |message|
|
|
15
|
-
if message["done"]
|
|
16
|
-
streaming_done = true
|
|
17
|
-
next
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
if message["finish_reason"]
|
|
21
|
-
finish_reason = message["finish_reason"]
|
|
22
|
-
agent.logger.debug("Working: finish_reason = #{finish_reason}")
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
new_state = state.run(message)
|
|
26
|
-
if new_state.class != state.class
|
|
27
|
-
agent.logger.info("STATE: #{state.display_name} -> #{new_state.display_name}")
|
|
28
|
-
end
|
|
29
|
-
state = new_state
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Only exit when task is actually complete, not just streaming done
|
|
33
|
-
if finish_reason == "stop"
|
|
34
|
-
agent.logger.debug("Working: Task complete, exiting to Idle")
|
|
35
|
-
break
|
|
36
|
-
elsif finish_reason == "tool_calls"
|
|
37
|
-
agent.logger.debug("Working: Tool calls finished, continuing conversation")
|
|
38
|
-
# Continue loop to process tool results
|
|
39
|
-
elsif streaming_done && finish_reason.nil?
|
|
40
|
-
agent.logger.debug("Working: Streaming done but no finish_reason, continuing")
|
|
41
|
-
# Continue for cases where finish_reason comes in separate chunk
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
agent.transition_to(States::Idle.new)
|
|
46
|
-
rescue StandardError => e
|
|
47
|
-
agent.logger.error(e)
|
|
48
|
-
agent.conversation.add(role: :tool, content: e.message)
|
|
49
|
-
agent.tui.say(e.message, colour: :red, newline: true)
|
|
50
|
-
agent.transition_to(States::Idle.new)
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
data/lib/elelem/tool.rb
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
class Tool
|
|
5
|
-
attr_reader :name, :description, :parameters
|
|
6
|
-
|
|
7
|
-
def initialize(name, description, parameters)
|
|
8
|
-
@name = name
|
|
9
|
-
@description = description
|
|
10
|
-
@parameters = parameters
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def banner
|
|
14
|
-
[name, parameters].join(": ")
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def valid?(args)
|
|
18
|
-
JSON::Validator.validate(parameters, args, insert_defaults: true)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def to_h
|
|
22
|
-
{
|
|
23
|
-
type: "function",
|
|
24
|
-
function: {
|
|
25
|
-
name: name,
|
|
26
|
-
description: description,
|
|
27
|
-
parameters: parameters
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
data/lib/elelem/toolbox/exec.rb
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Elelem
|
|
4
|
-
module Toolbox
|
|
5
|
-
class Exec < ::Elelem::Tool
|
|
6
|
-
attr_reader :tui
|
|
7
|
-
|
|
8
|
-
def initialize(configuration)
|
|
9
|
-
@tui = configuration.tui
|
|
10
|
-
super("exec", "Execute shell commands with pipe support", {
|
|
11
|
-
type: "object",
|
|
12
|
-
properties: {
|
|
13
|
-
command: {
|
|
14
|
-
type: "string",
|
|
15
|
-
description: "Shell command to execute (supports pipes, redirects, etc.)"
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
required: ["command"]
|
|
19
|
-
})
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def call(args)
|
|
23
|
-
command = args["command"]
|
|
24
|
-
output_buffer = []
|
|
25
|
-
|
|
26
|
-
tui.say(command, newline: true)
|
|
27
|
-
Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
|
|
28
|
-
stdin.close
|
|
29
|
-
streams = [stdout, stderr]
|
|
30
|
-
|
|
31
|
-
until streams.empty?
|
|
32
|
-
ready = IO.select(streams, nil, nil, 0.1)
|
|
33
|
-
|
|
34
|
-
if ready
|
|
35
|
-
ready[0].each do |io|
|
|
36
|
-
data = io.read_nonblock(4096)
|
|
37
|
-
output_buffer << data
|
|
38
|
-
|
|
39
|
-
if io == stderr
|
|
40
|
-
tui.say(data, colour: :red, newline: false)
|
|
41
|
-
else
|
|
42
|
-
tui.say(data, newline: false)
|
|
43
|
-
end
|
|
44
|
-
rescue IO::WaitReadable
|
|
45
|
-
next
|
|
46
|
-
rescue EOFError
|
|
47
|
-
streams.delete(io)
|
|
48
|
-
end
|
|
49
|
-
elsif !wait_thread.alive?
|
|
50
|
-
break
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
wait_thread.value
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
output_buffer.join
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|