aigency 0.1.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 +15 -0
  3. data/README.md +8 -1
  4. data/lib/aigency/aigent.rb +83 -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 +61 -0
  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 +23 -5
  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 +79 -7
  35. data/lib/aigency/tools.rb +0 -55
  36. data/lib/aigency/workspace/directory_deleter.rb +0 -24
  37. data/lib/aigency/workspace/directory_reader.rb +0 -34
  38. data/lib/aigency/workspace/file_deleter.rb +0 -24
  39. data/lib/aigency/workspace/file_reader.rb +0 -33
  40. data/lib/aigency/workspace/file_writer.rb +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c413cf37072efae1aa6df896884d291ee9bac175a1154e579d85833692f55959
4
- data.tar.gz: feaacbce278ae242f36bd8f2b3024d014bb200dad97679f6fb023986def493ec
3
+ metadata.gz: 8dc4124a357fd322bcbcd526f42cafc247b5f70e2a367db8bdec26dc2cd5af75
4
+ data.tar.gz: 8cea262865e5e6ffd4b9c1d7efc71b688814363a446684a71ae671bcbe9b316c
5
5
  SHA512:
6
- metadata.gz: '09ba427def6dcce5abcb433c76d44276c58af597ad15f703e1cfb3503e8a527f2944951314c6a2a53e3b91cc54005d54c996e659288d4726e71d0565a33e7cef'
7
- data.tar.gz: e4ef1fea768cd49fad82edf2ea4086b792118c545c7d12ea8772e0ba7c694793fa45c3024552ca35b9677d15a744a133a6f94bcf5f98a952008a6e5fefd9834b
6
+ metadata.gz: a989b935e3554e6cbb081130910b0e81a2c0146507d81ca527024d1f356c419dc8be026d2846ee85fb8233a767a4eef0b2337d1facad1d99b840214042283972
7
+ data.tar.gz: c545ffd5bba322f455931ee5658cfb0c8b72d22d4da07a231214968160d17d109791ae1293ee9564532171327dec405508edb05915173e12c64744a8dbf22216
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ ## 0.3.0 - October 9, 2026
2
+ - New plugin architecture
3
+ - Workspace and Sandbox plugins
4
+
5
+ ## 0.2.0 - October 2, 2026
6
+ - Log token usage
7
+ - Stale code
8
+ - Fix kwarg
9
+ - Log costs
10
+ - DirectoryReader includes hidden files
11
+ - Prohibited path spec
12
+ - Workspace::Helpers
13
+
14
+ ## 0.1.0 - September 12, 2026
15
+ - First release
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,26 +39,48 @@ module Aigency
38
39
  # Initialize a RubyLLM::Chat.
39
40
  #
40
41
  # @param extra [Array<String>] Additional instructions
42
+ # @param except [Array<Class<Tool>>] Tools to exclude from this chat
41
43
  # @return [RubyLLM::Chat]
42
- def brief(*extra)
44
+ def brief(*extra, except: [])
43
45
  chat = rubyllm_context.chat(provider: :openai)
44
- Tools.registry.each { |klass| chat.with_tool(klass.new(self)) }
46
+ (tools - except).each { |klass| chat.with_tool(klass.new(self)) }
45
47
  chat.with_instructions(build_instructions(extra), append: true)
46
- # @todo This should be configurable
47
48
  chat.with_params(max_tokens: max_tokens)
48
49
  end
49
50
 
50
- def load filename, *extra, out: $stdout
51
- stream File.read(filename), *extra, 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: [], 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
- brief(*extra).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
- end.tap { @log = original_log }
75
+ end
76
+ @log.puts "Input tokens: #{response.tokens.input}"
77
+ @log.puts "Output tokens: #{response.tokens.output}"
78
+ response
79
+ rescue StandardError => e
80
+ @log.puts "[#{e.class}] #{e.message}"
81
+ @log.puts e.backtrace
82
+ ensure
83
+ @log = original_log
61
84
  end
62
85
 
63
86
  # @param name [String]
@@ -66,38 +89,11 @@ module Aigency
66
89
  configs[name] ||= read_config(name)
67
90
  end
68
91
 
69
- # Initialize the aigent's tool configurations.
92
+ # Initialize the aigent's plugin configurations.
70
93
  #
71
- # @param only [Array<String>] Limit the tools being configured
72
- # @param overwrite [Boolean] Replace existing configs if true
73
- def setup(only: [], overwrite: false)
74
- only = [only].flatten.compact
75
- config_count = 0
76
- Tools.config_map.each do |name, text|
77
- next unless only.empty? || only.include?(name)
78
-
79
- dir = directory('config')
80
- FileUtils.mkdir_p dir
81
- path = File.join(dir, "#{name}.yml")
82
- if overwrite || !File.file?(path)
83
- doing = File.file?(path) ? 'Overwriting' : 'Writing new'
84
- log.puts "#{doing} config for #{name}"
85
- File.write path, text
86
- config_count += 1
87
- else
88
- log.puts "Config for #{name} exists, skipping"
89
- end
90
- end
91
- dir_count = 0
92
- Tools.directories.each do |path|
93
- absolute = directory(path)
94
- next if File.directory?(absolute)
95
-
96
- FileUtils.mkdir_p absolute
97
- dir_count += 1
98
- end
99
- log.puts "#{config_count} config files initialized."
100
- log.puts "#{dir_count} directories created."
94
+ # @return [void]
95
+ def setup
96
+ plugins.each { |plugin| boot plugin }
101
97
  end
102
98
 
103
99
  # Run code in an exclusive lock
@@ -138,8 +134,49 @@ module Aigency
138
134
  @instructions ||= build_instructions([])
139
135
  end
140
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
+
141
147
  private
142
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
+
143
180
  def configs
144
181
  @configs ||= {}
145
182
  end
@@ -153,7 +190,9 @@ module Aigency
153
190
  end
154
191
  full_path = File.join(directory, 'config', filename)
155
192
  if File.file?(full_path)
156
- YAML.load_file(full_path)
193
+ content = File.read(full_path)
194
+ .gsub(/\${([^}]+)}/) { ENV.fetch($1) }
195
+ YAML.load(content)
157
196
  else
158
197
  warn "Config name #{name} not found at #{full_path}"
159
198
  {}
@@ -180,10 +219,10 @@ module Aigency
180
219
  end
181
220
 
182
221
  def tool_prompts
183
- Tools.config_map
184
- .keys
185
- .map { |name| config(name)['prompt'] }
186
- .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
187
226
  end
188
227
 
189
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.1.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
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aigency
4
+ module Workspace
5
+ module Helpers
6
+ module_function
7
+
8
+ # @!method aigent
9
+ # @abstract
10
+ # @return [Aigency::Aigent]
11
+
12
+ # Expand a path relative to the aigent's workspace.
13
+ #
14
+ # @raise [PathError] if the expanded path is not inside the workspace
15
+ # @param path [String]
16
+ # @return [String]
17
+ def expand_in_workspace(path)
18
+ workspace = aigent.data('workspace')
19
+ resolved = File.expand_path(path, workspace)
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
50
+
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
58
+ end
59
+ end
60
+ end
61
+ 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,10 +2,28 @@
2
2
 
3
3
  module Aigency
4
4
  module Workspace
5
- require 'aigency/workspace/directory_reader'
6
- require 'aigency/workspace/file_reader'
7
- require 'aigency/workspace/file_writer'
8
- end
5
+ class PathError < ArgumentError; end
6
+
7
+ require 'aigency/workspace/helpers'
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'
9
19
 
10
- Tools.mkdir_p 'data/workspace'
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
+ )
28
+ end
11
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.1.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:
@@ -58,22 +114,38 @@ executables:
58
114
  extensions: []
59
115
  extra_rdoc_files: []
60
116
  files:
117
+ - CHANGELOG.md
61
118
  - README.md
62
119
  - Rakefile
63
120
  - exe/aigency
64
121
  - lib/aigency.rb
65
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
66
129
  - lib/aigency/shell.rb
67
130
  - lib/aigency/tool.rb
68
- - lib/aigency/tools.rb
69
131
  - lib/aigency/usability.rb
70
132
  - lib/aigency/version.rb
71
133
  - lib/aigency/workspace.rb
72
- - lib/aigency/workspace/directory_deleter.rb
73
- - lib/aigency/workspace/directory_reader.rb
74
- - lib/aigency/workspace/file_deleter.rb
75
- - lib/aigency/workspace/file_reader.rb
76
- - 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
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
77
149
  - scaffold/.gitignore
78
150
  - scaffold/Gemfile
79
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,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Workspace
5
- class DirectoryDeleter < Tool
6
- desc "Deletes a directory from your private workspace"
7
-
8
- def execute(dirname:)
9
- puts "Deleting directory #{dirname}"
10
-
11
- resolved = File.absolute_path(dirname, aigent.data('workspace'))
12
- return { dirname: dirname, success: false, error: "Permission denied" } unless resolved.start_with?(aigent.data('workspace'))
13
- return { dirname: dirname, success: false, error: "Permission denied" } if resolved.include?('/.git/') || resolved.end_with?('/.git')
14
- return { dirname: dirname, success: false, error: "Not a directory" } if File.file?(resolved)
15
- return { dirname: dirname, sucess: false, error: "Directory not found" } unless File.directory?(resolved)
16
-
17
- FileUtils.rm_rf(resolved)
18
- { dirname: dirname, success: true }
19
- end
20
- end
21
- end
22
- end
23
-
24
- Aigency::Tools.register Aigency::Workspace::DirectoryDeleter
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Workspace
5
- class DirectoryReader < Tool
6
- desc "Gets the contents of a directory from your private workspace"
7
-
8
- def execute(directory_name:)
9
- aigent.log.puts "[Workspace::DirectoryReader] Reading #{directory_name}"
10
- directory = aigent.data('workspace')
11
- resolved = File.absolute_path(directory_name, directory)
12
- return { directory: directory_name,
13
- success: false,
14
- error: "Permission denied. Path must be local to #{directory}" } unless resolved.start_with?(directory)
15
-
16
- if File.directory?(resolved)
17
- {
18
- directory: directory_name,
19
- success: true,
20
- entries: Dir[File.join(resolved, '*')].map { |dir| dir[resolved.length+1..]}
21
- }
22
- else
23
- {
24
- directory: directory_name,
25
- success: false,
26
- error: "Not a directory"
27
- }
28
- end
29
- end
30
- end
31
- end
32
- end
33
-
34
- Aigency::Tools.register Aigency::Workspace::DirectoryReader
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Workspace
5
- class FileDeleter < Tool
6
- desc "Deletes a file from your private workspace"
7
-
8
- def execute(filename:)
9
- aigent.log.puts "[Workspace::FileDeleter] Deleting file #{filename}"
10
-
11
- directory = aigent.data('directory')
12
- resolved = File.absolute_path(filename, directory)
13
- return { filename: filename, error: "Permission denied" } unless resolved.start_with?(directory)
14
- return { filename: filename, error: "Permission denied" } if resolved.include?('/.git/')
15
- return { filename: filename, error: "File does not exist" } unless File.file?(resolved)
16
-
17
- File.delete resolved
18
- { filename: filename, success: true }
19
- end
20
- end
21
- end
22
- end
23
-
24
- Aigency::Tools.register Aigency::Workspace::FileDeleter
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Aigency
4
- module Workspace
5
- class FileReader < Tool
6
- desc "Reads a file from your private workspace"
7
-
8
- def execute(filename:)
9
- aigent.log.puts "[Workspace::FileReader] Reading #{filename}"
10
-
11
- directory = aigent.data('workspace')
12
- resolved = File.absolute_path(filename, directory)
13
- return { filename: filename, success: false, error: "Permission denied" } unless resolved.start_with?(directory)
14
-
15
- if File.file?(resolved)
16
- {
17
- filename: filename,
18
- success: true,
19
- content: File.read(resolved)
20
- }
21
- else
22
- {
23
- filename: filename,
24
- success: false,
25
- error: "File not found"
26
- }
27
- end
28
- end
29
- end
30
- end
31
- end
32
-
33
- Aigency::Tools.register Aigency::Workspace::FileReader
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'fileutils'
4
-
5
- module Aigency
6
- module Workspace
7
- class FileWriter < Tool
8
- desc "Writes a file to your private workspace"
9
-
10
- def execute(filename:, content:)
11
- aigent.log.puts "[Workspace::FileWriter] Writing #{filename}"
12
-
13
- directory = aigent.data('workspace')
14
- resolved = File.absolute_path(filename, directory)
15
- return { filename: filename, success: false, error: "Permission denied" } unless resolved.start_with?(directory)
16
-
17
- FileUtils.mkdir_p File.dirname(resolved)
18
- File.write resolved, content
19
- return { filename: filename, success: true }
20
- end
21
- end
22
- end
23
- end
24
-
25
- Aigency::Tools.register Aigency::Workspace::FileWriter