aigency 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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +8 -1
  4. data/lib/aigency/aigent.rb +79 -44
  5. data/lib/aigency/plugin.rb +35 -0
  6. data/lib/aigency/sandbox/Dockerfile.tpl +13 -0
  7. data/lib/aigency/sandbox/docker-compose.yml.tpl +26 -0
  8. data/lib/aigency/sandbox/exec.rb +21 -0
  9. data/lib/aigency/sandbox/squid.conf.tpl +23 -0
  10. data/lib/aigency/sandbox.rb +21 -0
  11. data/lib/aigency/shell.rb +10 -3
  12. data/lib/aigency/version.rb +1 -1
  13. data/lib/aigency/workspace/cat.rb +11 -0
  14. data/lib/aigency/workspace/cp.rb +11 -0
  15. data/lib/aigency/workspace/diff.rb +11 -0
  16. data/lib/aigency/workspace/grep.rb +15 -0
  17. data/lib/aigency/workspace/head.rb +11 -0
  18. data/lib/aigency/workspace/helpers.rb +40 -3
  19. data/lib/aigency/workspace/ls.rb +11 -0
  20. data/lib/aigency/workspace/mkdir.rb +11 -0
  21. data/lib/aigency/workspace/mv.rb +11 -0
  22. data/lib/aigency/workspace/rm.rb +11 -0
  23. data/lib/aigency/workspace/shell.rb +59 -0
  24. data/lib/aigency/workspace/tail.rb +11 -0
  25. data/lib/aigency/workspace/wc.rb +13 -0
  26. data/lib/aigency/workspace/write.rb +36 -0
  27. data/lib/aigency/workspace.rb +21 -7
  28. data/lib/aigency.rb +2 -1
  29. data/scaffold/.env.example +4 -0
  30. data/scaffold/Gemfile +1 -1
  31. data/scaffold/README.md +31 -19
  32. data/scaffold/Rakefile +1 -1
  33. data/scaffold/config/aigent.yml +8 -5
  34. metadata +77 -7
  35. data/lib/aigency/tools.rb +0 -55
  36. data/lib/aigency/workspace/directory_deleter.rb +0 -23
  37. data/lib/aigency/workspace/directory_reader.rb +0 -33
  38. data/lib/aigency/workspace/file_deleter.rb +0 -22
  39. data/lib/aigency/workspace/file_reader.rb +0 -27
  40. data/lib/aigency/workspace/file_writer.rb +0 -26
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b9b5b24d49d87f69a17cec9420b4815db4646b37d8759951e4ced8431765800
4
- data.tar.gz: 04ecc1ddb1433cfcd2c18b7c3af420df7dd52a417ef6ab90a2a3de35adb2b99b
3
+ metadata.gz: 8dc4124a357fd322bcbcd526f42cafc247b5f70e2a367db8bdec26dc2cd5af75
4
+ data.tar.gz: 8cea262865e5e6ffd4b9c1d7efc71b688814363a446684a71ae671bcbe9b316c
5
5
  SHA512:
6
- metadata.gz: 6ab68e574725ebb40bad60ff6e807e480733c91f406d75c8e45b6678e16952e0cd67dde124df13ef9036ef82437bcdae97a4182169cbc615b30116f29c89667c
7
- data.tar.gz: 6a4e0866fc61258b3fee11e76baeadc72ade5d5d66b87f4d8d41ad2127ac27a50a5f7ef394d0d940e03d7cc389e567f4d7e814ebb1792ef56c125904a75c4dcc
6
+ metadata.gz: a989b935e3554e6cbb081130910b0e81a2c0146507d81ca527024d1f356c419dc8be026d2846ee85fb8233a767a4eef0b2337d1facad1d99b840214042283972
7
+ data.tar.gz: c545ffd5bba322f455931ee5658cfb0c8b72d22d4da07a231214968160d17d109791ae1293ee9564532171327dec405508edb05915173e12c64744a8dbf22216
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.3.0 - October 9, 2026
2
+ - New plugin architecture
3
+ - Workspace and Sandbox plugins
4
+
1
5
  ## 0.2.0 - October 2, 2026
2
6
  - Log token usage
3
7
  - Stale code
data/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  A framework for creating AI agents with RubyLLM.
4
4
 
5
+ ## Requierments
6
+
7
+ - Ruby 3.2+
8
+ - Docker 29+
9
+
10
+ (Note: Aigency is not tested on Windows. Some features are known to be Windows-incompatible.)
11
+
5
12
  ## Installation
6
13
 
7
14
  Install the gem from the command line:
@@ -38,7 +45,7 @@ rake setup
38
45
 
39
46
  The bare minimum configuration you need to do is setting `openai_api_base` and `openai_api_key` in `config/aigent.yml`.
40
47
 
41
- Test the aigent with with the `rake say` task.
48
+ Test the aigent with the `rake say` task.
42
49
 
43
50
  ```bash
44
51
  rake say["How many fibers are intertwined in a shredded wheat biscuit?"]
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'yaml'
5
+ require 'set'
5
6
 
6
7
  module Aigency
7
8
  class Aigent
@@ -38,30 +39,48 @@ module Aigency
38
39
  # Initialize a RubyLLM::Chat.
39
40
  #
40
41
  # @param extra [Array<String>] Additional instructions
41
- # @param tools [Array<Class<Tool>>] Available tools (default is all registered tools)
42
+ # @param except [Array<Class<Tool>>] Tools to exclude from this chat
42
43
  # @return [RubyLLM::Chat]
43
- def brief(*extra, tools: Tools.registry)
44
+ def brief(*extra, except: [])
44
45
  chat = rubyllm_context.chat(provider: :openai)
45
- tools.each { |klass| chat.with_tool(klass.new(self)) }
46
+ (tools - except).each { |klass| chat.with_tool(klass.new(self)) }
46
47
  chat.with_instructions(build_instructions(extra), append: true)
47
48
  chat.with_params(max_tokens: max_tokens)
48
49
  end
49
50
 
50
- def load filename, *extra, tools: Tools.registry, out: $stdout
51
- stream File.read(filename), *extra, tools: tools, out: out
51
+ # Prompt a chat with a file.
52
+ #
53
+ # @param filename [String]
54
+ # @param extra [Array<String>] Additional instructions
55
+ # @param except [Array<Class<Tool>>] Tools to exclude from this chat
56
+ # @param out [IO]
57
+ # @return [RubyLLM::Chat]
58
+ def load filename, *extra, except: [], out: $stdout
59
+ stream File.read(filename), *extra, except: except, out: out
52
60
  end
53
61
 
54
- def stream text, *extra, with: [], tools: Tools.registry, out: $stdout
62
+ # Prompt a chat.
63
+ #
64
+ # @param text [String]
65
+ # @param extra [Array<String>] Additional instructions
66
+ # @param except [Array<Class<Tool>>] Tools to exclude from this chat
67
+ # @param out [IO]
68
+ # @return [RubyLLM::Chat]
69
+ def stream text, *extra, attach: [], except: [], out: $stdout
55
70
  original_log = @log
56
71
  @log = out
57
- response = brief(*extra, tools: tools).ask text, with: with do |chunk|
72
+ response = brief(*extra, except: except).ask text, with: attach do |chunk|
58
73
  @log.print chunk.thinking&.text
59
74
  @log.print chunk.content
60
75
  end
61
76
  @log.puts "Input tokens: #{response.tokens.input}"
62
77
  @log.puts "Output tokens: #{response.tokens.output}"
63
- @log = original_log
64
78
  response
79
+ rescue StandardError => e
80
+ @log.puts "[#{e.class}] #{e.message}"
81
+ @log.puts e.backtrace
82
+ ensure
83
+ @log = original_log
65
84
  end
66
85
 
67
86
  # @param name [String]
@@ -70,38 +89,11 @@ module Aigency
70
89
  configs[name] ||= read_config(name)
71
90
  end
72
91
 
73
- # Initialize the aigent's tool configurations.
92
+ # Initialize the aigent's plugin configurations.
74
93
  #
75
- # @param only [Array<String>] Limit the tools being configured
76
- # @param overwrite [Boolean] Replace existing configs if true
77
- def setup(only: [], overwrite: false)
78
- only = [only].flatten.compact
79
- config_count = 0
80
- Tools.config_map.each do |name, text|
81
- next unless only.empty? || only.include?(name)
82
-
83
- dir = directory('config')
84
- FileUtils.mkdir_p dir
85
- path = File.join(dir, "#{name}.yml")
86
- if overwrite || !File.file?(path)
87
- doing = File.file?(path) ? 'Overwriting' : 'Writing new'
88
- log.puts "#{doing} config for #{name}"
89
- File.write path, text
90
- config_count += 1
91
- else
92
- log.puts "Config for #{name} exists, skipping"
93
- end
94
- end
95
- dir_count = 0
96
- Tools.directories.each do |path|
97
- absolute = directory(path)
98
- next if File.directory?(absolute)
99
-
100
- FileUtils.mkdir_p absolute
101
- dir_count += 1
102
- end
103
- log.puts "#{config_count} config files initialized."
104
- log.puts "#{dir_count} directories created."
94
+ # @return [void]
95
+ def setup
96
+ plugins.each { |plugin| boot plugin }
105
97
  end
106
98
 
107
99
  # Run code in an exclusive lock
@@ -142,8 +134,49 @@ module Aigency
142
134
  @instructions ||= build_instructions([])
143
135
  end
144
136
 
137
+ # @return [Array<Plugin>]
138
+ def plugins
139
+ config('aigent').fetch('plugins', [])
140
+ .map { |name| Plugin.registry[name] }
141
+ end
142
+
143
+ def tools
144
+ plugins.flat_map(&:tools)
145
+ end
146
+
145
147
  private
146
148
 
149
+ # @param plugin [Plugin]
150
+ def boot(plugin)
151
+ unless plugin.config.empty?
152
+ config_file = File.absolute_path("#{plugin.name}.yml", directory)
153
+ if File.exist?(config_file)
154
+ log.puts "File #{plugin.name}.yml exists, skipping"
155
+ else
156
+ log.puts "Creating #{plugin.name}.yml"
157
+ File.write config_file, plugin.config
158
+ end
159
+ end
160
+ plugin.directories.each do |dir|
161
+ absolute = File.absolute_path(dir, directory)
162
+ if File.directory?(absolute)
163
+ log.puts "Directory #{dir} exists, skipping"
164
+ else
165
+ log.puts "Creating #{dir}"
166
+ FileUtils.mkdir_p absolute
167
+ end
168
+ end
169
+ plugin.files.each do |file, content|
170
+ absolute = File.absolute_path(file, directory)
171
+ if File.exist?(absolute)
172
+ log.puts "File #{file} exists, skipping"
173
+ else
174
+ log.puts "Creating #{file}"
175
+ File.write absolute, content
176
+ end
177
+ end
178
+ end
179
+
147
180
  def configs
148
181
  @configs ||= {}
149
182
  end
@@ -157,7 +190,9 @@ module Aigency
157
190
  end
158
191
  full_path = File.join(directory, 'config', filename)
159
192
  if File.file?(full_path)
160
- YAML.load_file(full_path)
193
+ content = File.read(full_path)
194
+ .gsub(/\${([^}]+)}/) { ENV.fetch($1) }
195
+ YAML.load(content)
161
196
  else
162
197
  warn "Config name #{name} not found at #{full_path}"
163
198
  {}
@@ -184,10 +219,10 @@ module Aigency
184
219
  end
185
220
 
186
221
  def tool_prompts
187
- Tools.config_map
188
- .keys
189
- .map { |name| config(name)['prompt'] }
190
- .compact
222
+ # @todo Should aigents load prompts from config files that do not have
223
+ # a corresponding plugin?
224
+ plugins.map { |plugin| config(plugin.name)['prompt'] }
225
+ .compact
191
226
  end
192
227
 
193
228
  def load_requires
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ class Plugin
5
+ attr_reader :name, :config, :tools, :directories, :files
6
+
7
+ # @param name [String]
8
+ # @param tools [Array<Class<Tool>>]
9
+ # @param config [String]
10
+ # @param directories [Array<String>]
11
+ # @param files [Hash{String => String}]
12
+ def initialize name, tools: [], config: '', directories: [], files: {}
13
+ raise "A plugin named '#{name}' already exists" if self.class.registry.key?(name)
14
+ @name = name
15
+ @config = config
16
+ @tools = tools
17
+ @directories = directories
18
+ @files = files
19
+ self.class.registry[name] = self
20
+ end
21
+
22
+ def self.registry
23
+ @registry ||= {}
24
+ end
25
+
26
+ # @param plugin [Plugin]
27
+ def self.register plugin
28
+ registry[plugin.name] = plugin
29
+ end
30
+
31
+ def self.create(...)
32
+ register new(...)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,13 @@
1
+ FROM alpine:latest
2
+
3
+ RUN apk add --no-cache \
4
+ build-base \
5
+ musl-dev \
6
+ linux-headers \
7
+ git \
8
+ curl
9
+
10
+ RUN adduser -D -h /sandbox user
11
+ USER user
12
+
13
+ CMD ["sleep", "infinity"]
@@ -0,0 +1,26 @@
1
+ services:
2
+ app:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ volumes:
7
+ - ./data/sandbox:/sandbox
8
+ - ./data/sandbox_ssh:/sandbox/.ssh:ro
9
+ working_dir: /sandbox
10
+ command: ["sleep", "infinity"]
11
+
12
+ egress:
13
+ image: ubuntu/squid
14
+ networks: [sandbox, outside]
15
+ volumes:
16
+ - ./squid.conf:/etc/squid/squid.conf:ro
17
+
18
+ networks:
19
+ sandbox:
20
+ internal: true
21
+ driver: bridge
22
+ driver_opts:
23
+ com.docker.network.bridge.gateway_mode_ipv4: isolated
24
+ com.docker.network.bridge.gateway_mode_ipv6: isolated
25
+ outside:
26
+ driver: bridge
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Aigency
6
+ module Sandbox
7
+ class Exec < Aigency::Tool
8
+ desc 'Run a shell command in your sandbox'
9
+
10
+ params do
11
+ string :command, description: "The complete command to execute", required: true
12
+ end
13
+
14
+ def execute(command:)
15
+ aigent.log.puts "[Docker::Compose::Exec] #{command}"
16
+ output, error, result = Open3.capture3('docker', 'compose', 'exec', 'app', 'sh', '-c', command, chdir: aigent.directory)
17
+ { success: result.success?, stdout: output, stderr: error }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # Allowlist-only forward proxy.
2
+ # NOTE: this file deliberately does NOT `include /etc/squid/conf.d/*`, so the
3
+ # rules Ubuntu ships in the image can't silently widen the allowlist.
4
+
5
+ http_port 3128
6
+ visible_hostname egress
7
+ cache deny all # forward proxy only, nothing to disk
8
+ access_log daemon:/var/log/squid/access.log
9
+ logfile_rotate 0
10
+ forwarded_for off # don't leak the container's IP downstream
11
+
12
+ acl SSL_ports port 443
13
+ acl Safe_ports port 80 443
14
+ acl http_targets dstdomain example.com
15
+
16
+ acl SSH_ports port 22
17
+ acl ssh_targets dstdomain example.com
18
+
19
+ http_access allow CONNECT ssh_targets SSH_ports
20
+ http_access deny !Safe_ports
21
+ http_access deny CONNECT !SSL_ports
22
+ http_access allow http_targets
23
+ http_access deny all
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Sandbox
5
+ require 'aigency/sandbox/exec'
6
+
7
+ Plugin.register Plugin.new(
8
+ 'sandbox',
9
+ tools: [Sandbox::Exec],
10
+ directories: ['data/sandbox', 'data/sandbox_ssh'],
11
+ files: {
12
+ 'Dockerfile' => File.read(File.join(__dir__, 'sandbox', 'Dockerfile.tpl')),
13
+ 'docker-compose.yml' => File.read(File.join(__dir__, 'sandbox', 'docker-compose.yml.tpl')),
14
+ 'squid.conf' => File.read(File.join(__dir__, 'sandbox', 'squid.conf.tpl'))
15
+ },
16
+ config: <<~FILE
17
+ prompt: "The sandbox tools provide access to a Docker container."
18
+ FILE
19
+ )
20
+ end
21
+ end
data/lib/aigency/shell.rb CHANGED
@@ -7,9 +7,7 @@ module Aigency
7
7
  class Shell < Thor
8
8
  map %i[new] => :enlist
9
9
  desc "enlist [DIRECTORY]", "Enable the current user as an aigent"
10
- option :directory, type: :string, desc: 'Aigent directory', default: File.join(Dir.home, 'aigent')
11
- def enlist
12
- directory = options[:directory]
10
+ def enlist directory = 'aigent'
13
11
  puts "Installing aigent at #{directory}"
14
12
  if File.exist?(directory)
15
13
  if !File.directory?(directory)
@@ -22,5 +20,14 @@ module Aigency
22
20
  FileUtils.mkdir(directory) unless File.directory?(directory)
23
21
  FileUtils.copy_entry(File.join(GEM_PATH, 'scaffold'), directory)
24
22
  end
23
+
24
+ # desc "setup PLUGIN [...PLUGIN]", "Initialize plugin files"
25
+ # option :all, type: :boolean, alias: :a, description: "Set up all plugins", default: false
26
+ # option :overwrite, type: :boolean, alias: :o, description: "Overwrite existing files", default: false
27
+ # def setup *plugins
28
+ # # @todo Assuming all for now
29
+ # aigent = Aigent.new(aigent)
30
+ # aigent.setup
31
+ # end
25
32
  end
26
33
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aigency
4
- VERSION = "0.2.0"
4
+ VERSION = '0.3.0'
5
5
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Cat < Shell
6
+ desc 'Run the `cat` shell command in your workspace'
7
+
8
+ def command = 'cat'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Cp < Shell
6
+ desc 'Run the `cp` shell command in your workspace'
7
+
8
+ def command = 'cp'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Diff < Shell
6
+ desc 'Run the `diff` shell command in your workspace'
7
+
8
+ def command = 'diff'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Grep < Shell
6
+ desc 'Run the `grep` shell command in your workspace'
7
+
8
+ def command = 'grep'
9
+
10
+ def non_path_arguments = %w[PATTERN]
11
+
12
+ def prohibited_arguments = %w[-f --file]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Head < Shell
6
+ desc 'Run the `head` shell command in your workspace'
7
+
8
+ def command = 'head'
9
+ end
10
+ end
11
+ end
@@ -3,21 +3,58 @@
3
3
  module Aigency
4
4
  module Workspace
5
5
  module Helpers
6
+ module_function
7
+
6
8
  # @!method aigent
7
9
  # @abstract
8
10
  # @return [Aigency::Aigent]
9
11
 
10
- # Expand a path relative to the aigent's workspace directory.
12
+ # Expand a path relative to the aigent's workspace.
11
13
  #
12
14
  # @raise [PathError] if the expanded path is not inside the workspace
13
15
  # @param path [String]
14
16
  # @return [String]
15
- def expand_to_workspace(path)
17
+ def expand_in_workspace(path)
16
18
  workspace = aigent.data('workspace')
17
19
  resolved = File.expand_path(path, workspace)
18
20
  return resolved if resolved == workspace || resolved.start_with?("#{workspace}/")
21
+ raise PathError, "Permission denied: `#{path}` is not relative to your workspace"
22
+ end
23
+
24
+ # @raise [PathError] if the expanded path is not inside the workspace
25
+ # @param arguments [Array<String>]
26
+ # @param start [Integer]
27
+ # @return [void]
28
+ def validate_path_arguments(arguments, start = 0)
29
+ current = 0
30
+ glued_shorts = arguments.select { |arg| arg =~ /^-[^\-].+/ }
31
+ unless glued_shorts.empty?
32
+ raise PathError, "Permission denied: glued short options (#{glued_shorts.inspect}) are not allowed"
33
+ end
34
+ workspace = aigent.data('workspace')
35
+ path_arguments(arguments).each do |arg|
36
+ if !arg.start_with?('-')
37
+ current += 1
38
+ next unless current > start
39
+ end
40
+ path = split_path_part(arg)
41
+ resolved = File.expand_path(path, workspace)
42
+ next if resolved == workspace || resolved.start_with?("#{workspace}/")
43
+ raise PathError, "Permission denied: `#{path}` is not relative to your workspace"
44
+ end
45
+ end
46
+
47
+ def path_arguments(arguments)
48
+ arguments.reject { |arg| arg =~ /^-[^\-]$/ || (arg.start_with?('--') && !arg.include?('=')) }
49
+ end
19
50
 
20
- raise PathError, "Permission denied: `#{path}` is not relative to your root workspace"
51
+ def split_path_part argument
52
+ return argument unless argument.start_with?('-')
53
+ if argument.start_with?('--')
54
+ argument.split('=', 2).last
55
+ else
56
+ argument[2..]
57
+ end
21
58
  end
22
59
  end
23
60
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Ls < Shell
6
+ desc 'Run the `ls` shell command in your workspace'
7
+
8
+ def command = 'ls'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Mkdir < Shell
6
+ desc 'Run the `mkdir` shell command in your workspace'
7
+
8
+ def command = 'mkdir'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Mv < Shell
6
+ desc 'Run the `mv` shell command in your workspace'
7
+
8
+ def command = 'mv'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Rm < Shell
6
+ desc 'Run the `rm` shell command in your workspace'
7
+
8
+ def command = 'rm'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Aigency
6
+ module Workspace
7
+ class Shell < Tool
8
+ include Helpers
9
+
10
+ # @abstract
11
+ # @return [String]
12
+ def command
13
+ raise 'Not implemented'
14
+ end
15
+
16
+ def non_path_arguments = []
17
+
18
+ def prohibited_arguments = []
19
+
20
+ def execute(directory: '.', arguments: [])
21
+ aigent.log.puts "[Workspace::Shell] #{command} #{arguments.inspect}"
22
+ if any_prohibited?(arguments)
23
+ return { success: false, error: "Permission denied: the following arguments to `#{command}` are prohibited: #{prohibited_arguments.inspect}" }
24
+ end
25
+ resolved = expand_in_workspace(directory)
26
+ if arguments != ['--help'] && arguments[0, non_path_arguments.length].any? { |arg| arg.start_with?('-') }
27
+ return { success: false, error: non_path_argument_error }
28
+ end
29
+ validate_path_arguments(arguments, non_path_arguments.length)
30
+ output, error, result = Open3.capture3(command, *arguments, chdir: resolved)
31
+ { success: result.success?, stdout: output, stderr: error }
32
+ rescue StandardError => e
33
+ { success: false, error: "[#{e.class}] #{e.message}" }
34
+ end
35
+
36
+ def self.inherited other
37
+ other.params do
38
+ string :directory, description: "The working directory relative to workspace", required: false
39
+ array :arguments, of: :string, description: "Command arguments", required: false
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def non_path_argument_error
46
+ arg_count = if non_path_arguments.length > 1
47
+ "#{non_path_arguments.length} arguments"
48
+ else
49
+ 'argument'
50
+ end
51
+ "Permission denied: first #{arg_count} must be [#{non_path_arguments.join(', ')}] unless the only argument is `--help`"
52
+ end
53
+
54
+ def any_prohibited?(arguments)
55
+ arguments.any? { |arg| arg.start_with?(*prohibited_arguments) }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Tail < Shell
6
+ desc 'Run the `tail` shell command in your workspace'
7
+
8
+ def command = 'tail'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ class Wc < Shell
6
+ desc 'Run the `wc` shell command in your workspace'
7
+
8
+ def command = 'wc'
9
+
10
+ def prohibited_arguments = %w[--files0-from]
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Aigency
6
+ module Workspace
7
+ # @note Unlike most of the Workspace tools, this one does not execute a shell
8
+ # command. It uses ruby's File module instead.
9
+ #
10
+ class Write < Tool
11
+ include Helpers
12
+
13
+ desc 'Write or append to a file in the workspace'
14
+
15
+ params do
16
+ string :filename, description: "The filename relative to the workspace", required: true
17
+ string :content, description: "The data to write to the file", required: true
18
+ string :append, description: "Append if true or overwrite if false", required: true
19
+ end
20
+
21
+ def execute(filename:, content:, append:)
22
+ aigent.log.puts "[Workspace::Write] #{filename}"
23
+ resolved = expand_in_workspace(filename)
24
+ write_mode = if append
25
+ "a"
26
+ else
27
+ "w"
28
+ end
29
+ File.write resolved, content, mode: "#{write_mode}b"
30
+ { success: true, filename: filename }
31
+ rescue StandardError => e
32
+ { success: false, error: "[#{e.class}] #{e.message}" }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,14 +2,28 @@
2
2
 
3
3
  module Aigency
4
4
  module Workspace
5
+ class PathError < ArgumentError; end
6
+
5
7
  require 'aigency/workspace/helpers'
6
- require 'aigency/workspace/directory_deleter'
7
- require 'aigency/workspace/directory_reader'
8
- require 'aigency/workspace/file_reader'
9
- require 'aigency/workspace/file_writer'
8
+ require 'aigency/workspace/shell'
9
+ require 'aigency/workspace/cat'
10
+ require 'aigency/workspace/cp'
11
+ require 'aigency/workspace/diff'
12
+ require 'aigency/workspace/grep'
13
+ require 'aigency/workspace/ls'
14
+ require 'aigency/workspace/mkdir'
15
+ require 'aigency/workspace/mv'
16
+ require 'aigency/workspace/rm'
17
+ require 'aigency/workspace/wc'
18
+ require 'aigency/workspace/write'
10
19
 
11
- class PathError < RuntimeError; end
20
+ Plugin.register Plugin.new(
21
+ 'workspace',
22
+ directories: ['data/workspace'],
23
+ tools: [Cat, Cp, Diff, Grep, Ls, Mkdir, Mv, Rm, Wc, Write],
24
+ config: <<~FILE
25
+ prompt: "The workspace tools provide access to a readable and writeable directory."
26
+ FILE
27
+ )
12
28
  end
13
-
14
- Tools.mkdir_p 'data/workspace'
15
29
  end
data/lib/aigency.rb CHANGED
@@ -6,12 +6,13 @@ require 'ruby_llm'
6
6
  require 'aigency/version'
7
7
  require 'aigency/usability'
8
8
  require 'aigency/tool'
9
- require 'aigency/tools'
10
9
  require 'aigency/aigent'
10
+ require 'aigency/plugin'
11
11
  require 'aigency/shell'
12
12
 
13
13
  # Extensions
14
14
  require 'aigency/workspace'
15
+ require 'aigency/sandbox'
15
16
 
16
17
  module Aigency
17
18
  GEM_PATH = File.absolute_path(File.join(__dir__, '..'))
@@ -0,0 +1,4 @@
1
+ # Environment variables to load into aigent config files
2
+
3
+ OPENAI_API_BASE=https://api.openai.com/v1
4
+ OPENAI_API_KEY=change_me
data/scaffold/Gemfile CHANGED
@@ -7,4 +7,4 @@ gem "rake", "~> 13.0"
7
7
  gem "whenever", "~> 1.1"
8
8
 
9
9
  gem "ruby_llm"
10
- gem "aigency", path: "../aigency"
10
+ gem "aigency"
data/scaffold/README.md CHANGED
@@ -4,27 +4,23 @@ An aigent (AI agent) is an intelligent assistant powered by language models thro
4
4
 
5
5
  ## Configuration
6
6
 
7
- ### Install dependencies
7
+ ### 1. Install dependencies
8
8
 
9
9
  ```bash
10
10
  bundle install
11
11
  ```
12
12
 
13
- ### Initial setup
13
+ ### 2. Configure OpenAI API Connection
14
+
15
+ Copy the example env file:
14
16
 
15
17
  ```bash
16
- rake setup
18
+ cp .env.example .env
17
19
  ```
18
20
 
19
- ### 1. Configure the Aigent
20
-
21
- Configure the aigent's settings in `config/aigent.yml`.
22
-
23
- #### OpenAI-Compatible Providers
24
-
25
- If you're using a local AI server or any other provider that is compatible with OpenAI, you can set the OPENAI_API_BASE config option to point there instead (e.g., `http://localhost:8080/v1`).
21
+ Modify the `OPENAI_API_BASE` and `OPENAI_API_KEY` as needed. If you're using a local AI server or any other provider that is compatible with OpenAI, you can set the OPENAI_API_BASE config option to point there instead (e.g., `http://localhost:8080/v1`).
26
22
 
27
- ### 2. Define the Aigent's Personality
23
+ ### 3. Define the Aigent's Personality
28
24
 
29
25
  Edit `IDENTITY.md` to define how your aigent should behave:
30
26
 
@@ -34,6 +30,22 @@ You are a professional assistant named Alex. You respond in a friendly but conci
34
30
 
35
31
  This file sets the foundation for all the aigent's responses.
36
32
 
33
+ ### 4. Configure Plugins
34
+
35
+ Aigency ships with two plugins: `workspace` and `sandbox`. The `workspace` plugin is enabled by default. It's recommended that you use one or the other, but not both.
36
+
37
+ The `workspace` plugin gives your aigent a directory where it can manage its own files.
38
+
39
+ The `sandbox` plugin provides a container where the aigent can not only manage files, but also run commands and scripts.
40
+
41
+ The `workspace` plugin is sufficient for many aigents. Switch to `sandbox` if you need your aigent to perform complex tasks.
42
+
43
+ ### 5. Run setup
44
+
45
+ ```bash
46
+ rake setup
47
+ ```
48
+
37
49
  ## Usage
38
50
 
39
51
  ### Interactive Mode
@@ -41,16 +53,12 @@ This file sets the foundation for all the aigent's responses.
41
53
  Talk to your aigent using the Rake task:
42
54
 
43
55
  ```bash
44
- rake say["Tell me about project progress"]
56
+ rake say["How many fibers are intertwined in a shredded wheat biscuit?"]
45
57
  ```
46
58
 
47
- ### The Workspace
48
-
49
- Your aigent has read/write access to everything in the `data/workspace` directory.
50
-
51
59
  ### Scheduling Tasks
52
60
 
53
- The project includes the `whenever` gem for scheduling. Example of a scheduled task:
61
+ Aigents use the `whenever` gem for scheduling. Example of a scheduled task:
54
62
 
55
63
  ```ruby
56
64
  # config/schedule.rb
@@ -69,6 +77,10 @@ bundle exec whenever --update-crontab
69
77
 
70
78
  (Note: the `whenever` gem does not work on Windows.)
71
79
 
72
- ## Aigent Tools
80
+ ### Using the Sandbox
81
+
82
+ If you enable the `sandbox` plugin, you'll need to start the container:
73
83
 
74
- You can give your aigent more functionality by installing additional tools. It ships with the `Workspace` tool, which gives it full read/write access to the `data/workspace` directory.
84
+ ```bash
85
+ docker compose up -d
86
+ ```
data/scaffold/Rakefile CHANGED
@@ -23,7 +23,7 @@ task :say, [:text] do |_, args|
23
23
  aigent.stream args[:text]
24
24
  end
25
25
 
26
- desc "Set up aigent configurations"
26
+ desc "Set up aigent plugins"
27
27
  task :setup do
28
28
  aigent = Aigency::Aigent.new('.')
29
29
  aigent.setup
@@ -1,6 +1,9 @@
1
- name: an Aigent
2
- openai_api_base: https://api.openai.com/v1
3
- openai_api_key: CHANGE_ME
1
+ name: Aigent
2
+ openai_api_base: ${OPENAI_API_BASE}
3
+ openai_api_key: ${OPENAI_API_KEY}
4
4
  # max_tokens: 65536
5
- # require:
6
- # - aigency-example
5
+ plugins:
6
+ # You should only need either a workspace or a sandbox, not both. If you
7
+ # enable one, it's recommended that you disable the other.
8
+ # - sandbox
9
+ - workspace
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aigency
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fred Snyder
@@ -9,6 +9,20 @@ 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: dotenv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.2'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: fileutils
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +65,48 @@ dependencies:
51
65
  - - "~>"
52
66
  - !ruby/object:Gem::Version
53
67
  version: '1.5'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: simplecov
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.21'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.21'
54
110
  email:
55
111
  - fsnyder@castwide.com
56
112
  executables:
@@ -64,18 +120,32 @@ files:
64
120
  - exe/aigency
65
121
  - lib/aigency.rb
66
122
  - lib/aigency/aigent.rb
123
+ - lib/aigency/plugin.rb
124
+ - lib/aigency/sandbox.rb
125
+ - lib/aigency/sandbox/Dockerfile.tpl
126
+ - lib/aigency/sandbox/docker-compose.yml.tpl
127
+ - lib/aigency/sandbox/exec.rb
128
+ - lib/aigency/sandbox/squid.conf.tpl
67
129
  - lib/aigency/shell.rb
68
130
  - lib/aigency/tool.rb
69
- - lib/aigency/tools.rb
70
131
  - lib/aigency/usability.rb
71
132
  - lib/aigency/version.rb
72
133
  - lib/aigency/workspace.rb
73
- - lib/aigency/workspace/directory_deleter.rb
74
- - lib/aigency/workspace/directory_reader.rb
75
- - lib/aigency/workspace/file_deleter.rb
76
- - lib/aigency/workspace/file_reader.rb
77
- - lib/aigency/workspace/file_writer.rb
134
+ - lib/aigency/workspace/cat.rb
135
+ - lib/aigency/workspace/cp.rb
136
+ - lib/aigency/workspace/diff.rb
137
+ - lib/aigency/workspace/grep.rb
138
+ - lib/aigency/workspace/head.rb
78
139
  - lib/aigency/workspace/helpers.rb
140
+ - lib/aigency/workspace/ls.rb
141
+ - lib/aigency/workspace/mkdir.rb
142
+ - lib/aigency/workspace/mv.rb
143
+ - lib/aigency/workspace/rm.rb
144
+ - lib/aigency/workspace/shell.rb
145
+ - lib/aigency/workspace/tail.rb
146
+ - lib/aigency/workspace/wc.rb
147
+ - lib/aigency/workspace/write.rb
148
+ - scaffold/.env.example
79
149
  - scaffold/.gitignore
80
150
  - scaffold/Gemfile
81
151
  - scaffold/IDENTITY.md
data/lib/aigency/tools.rb DELETED
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Tools
5
- # @return [Array<Class<Tool>>]
6
- def self.registry
7
- @registry ||= []
8
- end
9
-
10
- # @return [Hash{String => String}]
11
- def self.config_map
12
- @configs ||= {}
13
- end
14
-
15
- # @return [Array<String>]
16
- def self.directories
17
- @directories ||= []
18
- end
19
-
20
- # Register a tool.
21
- #
22
- # @param klass [Class<Tool>]
23
- # @return [void]
24
- def self.register klass
25
- registry.push klass
26
- end
27
-
28
- # Define a default configuration.
29
- #
30
- # @param name [String] The name of the config section
31
- # @param text [String] YAML-formatted config data
32
- # @return [void]
33
- def self.configure name, text
34
- config_map[name] = text
35
- end
36
-
37
- # Define directories to be created relative to an aigent's root.
38
- #
39
- def self.mkdir_p *paths
40
- directories.concat paths
41
- end
42
- end
43
-
44
- Tools.configure 'aigent', <<~EOF
45
- name: an Aigent
46
- openai_api_base: https://api.openai.com/v1
47
- openai_api_key: CHANGE_ME
48
- require:
49
- - aigency/email
50
- - aigency/wikipedia
51
- - aigency/workspace
52
- EOF
53
-
54
- Tools.mkdir_p 'data/chats', 'data/log'
55
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Workspace
5
- class DirectoryDeleter < Tool
6
- include Helpers
7
-
8
- desc "Delete a directory and all of its contents from your private workspace"
9
-
10
- def execute(directory_name:)
11
- aigent.log.puts "[Workspace::DirectoryDeleter] #{directory_name}"
12
-
13
- resolved = expand_to_workspace(directory_name)
14
- FileUtils.rm_rf(resolved)
15
- { dirname: directory_name, success: true }
16
- rescue StandardError => e
17
- { success: false, error: "[#{e.class}] #{e.message}"}
18
- end
19
- end
20
- end
21
- end
22
-
23
- Aigency::Tools.register Aigency::Workspace::DirectoryDeleter
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Workspace
5
- class DirectoryReader < Tool
6
- include Helpers
7
-
8
- desc "Get the contents of a directory from your private workspace"
9
-
10
- def execute(directory_name:)
11
- aigent.log.puts "[Workspace::DirectoryReader] Reading #{directory_name}"
12
- resolved = expand_to_workspace(directory_name)
13
- if File.directory?(resolved)
14
- {
15
- directory: directory_name,
16
- success: true,
17
- entries: Dir.entries(resolved)[2..]
18
- }
19
- else
20
- {
21
- directory: directory_name,
22
- success: false,
23
- error: "Not a directory"
24
- }
25
- end
26
- rescue StandardError => e
27
- { success: false, error: "[#{e.class}] #{e.message}" }
28
- end
29
- end
30
- end
31
- end
32
-
33
- Aigency::Tools.register Aigency::Workspace::DirectoryReader
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Workspace
5
- class FileDeleter < Tool
6
- include Helpers
7
-
8
- desc "Delete a file from your private workspace"
9
-
10
- def execute(filename:)
11
- aigent.log.puts "[Workspace::FileDeleter] Deleting file #{filename}"
12
- resolved = expand_to_workspace(filename)
13
- File.delete resolved
14
- { filename: filename, success: true }
15
- rescue StandardError => e
16
- { success: false, error: "[#{e.class}] #{e.message}" }
17
- end
18
- end
19
- end
20
- end
21
-
22
- Aigency::Tools.register Aigency::Workspace::FileDeleter
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Workspace
5
- class FileReader < Tool
6
- include Helpers
7
-
8
- desc "Read a file from your private workspace"
9
-
10
- def execute(filename:)
11
- aigent.log.puts "[Workspace::FileReader] Reading #{filename}"
12
-
13
- resolved = expand_to_workspace(filename)
14
-
15
- {
16
- success: true,
17
- filename: filename,
18
- content: File.read(resolved)
19
- }
20
- rescue StandardError => e
21
- { success: false, error: "[#{e.class}] #{e.message}"}
22
- end
23
- end
24
- end
25
- end
26
-
27
- Aigency::Tools.register Aigency::Workspace::FileReader
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'fileutils'
4
-
5
- module Aigency
6
- module Workspace
7
- class FileWriter < Tool
8
- include Helpers
9
-
10
- desc "Write a file to your private workspace"
11
-
12
- def execute(filename:, content:)
13
- aigent.log.puts "[Workspace::FileWriter] Writing #{filename}"
14
-
15
- resolved = expand_to_workspace(filename)
16
- FileUtils.mkdir_p File.dirname(resolved)
17
- File.write resolved, content
18
- return { filename: filename, success: true }
19
- rescue StandardError => e
20
- { success: false, error: "[#{e.class}] #{e.message}" }
21
- end
22
- end
23
- end
24
- end
25
-
26
- Aigency::Tools.register Aigency::Workspace::FileWriter