Class: Zephira::Agent
- Inherits:
-
Object
- Object
- Zephira::Agent
- Defined in:
- lib/zephira/agent.rb,
lib/zephira/agent/status.rb
Defined Under Namespace
Classes: Status
Constant Summary collapse
- COMPACTION_TRIGGER_RATIO =
0.8- COMPACTION_TARGET_RATIO =
0.5- SYSTEM_PROMPT =
<<~PROMPT You are a helpful command line agent called Zephira. You can run commands and tools to assist the user. You should make full use of the tools that are available to you. You can use the knowledge you already have to help the user, but if you don't know something, you should: - Use the tools available to you to find the answer - Ask the user for more information - Do not make up answers or pretend to know something you don't. Return all responses in a format that is easy to read in a terminal: - Do NOT return responses in Markdown format. - You can use unicode characters to make your output more readable. - You can use emojis to make your output more engaging (but don't overdo it). - You can use colors to highlight important information. - You can use formatting to make your output more readable. - You can return links to documentation or other resources as full URLs. You can use the following formatting tokens in your responses: - #{Formatter.available_formats.join("\n - ")} If you are trying to perform operations that don't seem to be working, you should stop what you're doing and tell the user that you are unable to perform the operation, and tell the user why. When updating a file using the `update_file` tool, always output the complete file content — never partial content or diffs. You should not try to guess what the user is trying to do, or try to perform operations that are not explicitly requested by the user. Additional instructions provided by the user. The project-local instructions should overrule the global instructions: Global instructions (loaded from ~/.zephira/additional_instructions.md): @@@GLOBAL_ADDITIONAL_INSTRUCTIONS@@@ Project instructions (loaded from ./.zephira/additional_instructions.md): @@@PROJECT_ADDITIONAL_INSTRUCTIONS@@@ The user's current `date` is: @@@DATE@@@ The user's current `uname -a` is: @@@UNAME@@@ The user's current `pwd` is: @@@PWD@@@ The user's current `ls -R` is: @@@LSR@@@ PROMPT
- LOGO =
<<~'LOGO' ░▒▓████████▓▒░░▒▓████████▓▒░░▒▓███████▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓███████▓▒░ ░▒▓██████▓▒░ ░▒▓██▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░ ░▒▓██▓▒░ ░▒▓██████▓▒░ ░▒▓███████▓▒░ ░▒▓████████▓▒░░▒▓█▓▒░░▒▓███████▓▒░ ░▒▓████████▓▒░ ░▒▓██▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░ ░▒▓████████▓▒░░▒▓████████▓▒░░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░ LOGO
Instance Attribute Summary collapse
-
#commands ⇒ Object
readonly
Returns the value of attribute commands.
-
#completions ⇒ Object
readonly
Returns the value of attribute completions.
-
#history ⇒ Object
readonly
Returns the value of attribute history.
-
#logger ⇒ Object
readonly
Returns the value of attribute logger.
-
#model ⇒ Object
Returns the value of attribute model.
-
#status ⇒ Object
readonly
Returns the value of attribute status.
-
#tools ⇒ Object
readonly
Returns the value of attribute tools.
-
#verbose ⇒ Object
Returns the value of attribute verbose.
Instance Method Summary collapse
- #compact_history(force: false) ⇒ Object
- #compact_if_needed ⇒ Object
- #dispatch_command(input) ⇒ Object
- #echo_user_input(input) ⇒ Object
-
#initialize ⇒ Agent
constructor
A new instance of Agent.
- #print_intro ⇒ Object
- #process_user_message(input) ⇒ Object
- #render_status_bar ⇒ Object
- #run_command(name:, args:) ⇒ Object
- #run_inference_with_spinner(messages) ⇒ Object
- #run_loop ⇒ Object
- #run_tool(name:, args:) ⇒ Object
- #seed_readline_history ⇒ Object
- #thinking(model_class) ⇒ Object
- #update_status(msg) ⇒ Object
Constructor Details
#initialize ⇒ Agent
Returns a new instance of Agent.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/zephira/agent.rb', line 69 def initialize @verbose = false log_file_path = File.join(Dir.pwd, ".zephira", "session.log") @logger = Logger.new(file_path: log_file_path) @status = Status.new(self) @spinner = nil @output_mutex = Mutex.new tool_dirs = [File.("tools", __dir__)] command_dirs = [File.("commands", __dir__)] completion_dirs = [File.("completions", __dir__)] @tools = Tools.load(paths: tool_dirs) @commands = Commands.load(paths: command_dirs) @completions = Completions.load(paths: completion_dirs) @history = History.new @history. @uname = `uname -a`.strip @pwd = `pwd`.strip @model = resolve_model end |
Instance Attribute Details
#commands ⇒ Object (readonly)
Returns the value of attribute commands.
66 67 68 |
# File 'lib/zephira/agent.rb', line 66 def commands @commands end |
#completions ⇒ Object (readonly)
Returns the value of attribute completions.
66 67 68 |
# File 'lib/zephira/agent.rb', line 66 def completions @completions end |
#history ⇒ Object (readonly)
Returns the value of attribute history.
66 67 68 |
# File 'lib/zephira/agent.rb', line 66 def history @history end |
#logger ⇒ Object (readonly)
Returns the value of attribute logger.
66 67 68 |
# File 'lib/zephira/agent.rb', line 66 def logger @logger end |
#model ⇒ Object
Returns the value of attribute model.
67 68 69 |
# File 'lib/zephira/agent.rb', line 67 def model @model end |
#status ⇒ Object (readonly)
Returns the value of attribute status.
66 67 68 |
# File 'lib/zephira/agent.rb', line 66 def status @status end |
#tools ⇒ Object (readonly)
Returns the value of attribute tools.
66 67 68 |
# File 'lib/zephira/agent.rb', line 66 def tools @tools end |
#verbose ⇒ Object
Returns the value of attribute verbose.
67 68 69 |
# File 'lib/zephira/agent.rb', line 67 def verbose @verbose end |
Instance Method Details
#compact_history(force: false) ⇒ Object
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/zephira/agent.rb', line 114 def compact_history(force: false) current = Tokens.estimate(JSON.dump(@history.)) threshold = (@model.context_limit * COMPACTION_TRIGGER_RATIO).to_i target = force ? [current / 2, 1].max : (@model.context_limit * COMPACTION_TARGET_RATIO).to_i return false if @history..empty? return false if !force && current <= threshold puts Formatter.color(:grey, " ✦ Compacting history (~#{current} tokens)...") @history.compact( response_model: @model, api_key: Config.read("ZEPHIRA_API_KEY"), agent: self, token_limit: target ) after = Tokens.estimate(JSON.dump(@history.)) puts Formatter.color(:grey, " ✦ History compacted (~#{after} tokens).") true end |
#compact_if_needed ⇒ Object
134 135 136 |
# File 'lib/zephira/agent.rb', line 134 def compact_if_needed compact_history(force: false) end |
#dispatch_command(input) ⇒ Object
223 224 225 226 |
# File 'lib/zephira/agent.rb', line 223 def dispatch_command(input) parts = input[1..].strip.split run_command(name: parts.first, args: parts[1..] || []) end |
#echo_user_input(input) ⇒ Object
212 213 214 215 216 217 218 219 220 221 |
# File 'lib/zephira/agent.rb', line 212 def echo_user_input(input) rows = screen_rows puts puts Formatter.color(:grey, "=" * screen_width) puts "\n" * rows print TTY::Cursor.up(rows) puts Formatter.color(:grey, "User:") puts Formatter.format(input, indent: 2) puts end |
#print_intro ⇒ Object
183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/zephira/agent.rb', line 183 def print_intro logo_width = LOGO.each_line.first.chomp.length logo_indent = [(screen_width - logo_width) / 2, 0].max puts Formatter.format(Formatter.color(:green, LOGO), indent: logo_indent) puts puts "#{Formatter.color(:grey, "System:")}\n Zephira starting... #{Formatter.color(:green, "Ready!")}" puts puts Formatter.color(:grey, "Zephira:") puts " Hello! I am Zephira, your command line assistant. How can I help you today?" puts " Type your command or question below. If you're not sure what to ask, you can" puts " ask me what I can do for you... or type '/help' for a list of commands." end |
#process_user_message(input) ⇒ Object
228 229 230 231 232 233 234 235 236 237 238 239 240 |
# File 'lib/zephira/agent.rb', line 228 def (input) history.append(role: "user", content: input) = [system_prompt] + history..map { || .slice(:role, :content, :tool_call_id, :tool_calls) } response = run_inference_with_spinner() if response history.append(role: "assistant", content: response) puts Formatter.color(:grey, "\nZephira:") puts Formatter.format(response, indent: 2) puts end end |
#render_status_bar ⇒ Object
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
# File 'lib/zephira/agent.rb', line 196 def context_used = Tokens.estimate(JSON.dump(@history.)) context_limit = @model.context_limit # Percent of context window REMAINING (limit - used) / limit, clamped to 0..100. context_pct = ((context_limit - context_used).to_f / context_limit * 100).clamp(0, 100).to_i width = screen_width print TTY::Cursor.move_to(0, screen_height - 3) puts Formatter.color(:grey, "-" * width) sandbox_label = ENV["ZEPHIRA_IN_SANDBOX"] == "1" ? "sandboxed" : "⚠ DANGER: NO SANDBOX" sandbox_color = ENV["ZEPHIRA_IN_SANDBOX"] == "1" ? :green : :red right_text = "ctrl+c to exit | '/help' + enter to see commands | #{context_pct}% context left" padding = [width - sandbox_label.length - right_text.length, 1].max puts Formatter.color(sandbox_color, sandbox_label) + " " * padding + Formatter.color(:grey, right_text) end |
#run_command(name:, args:) ⇒ Object
110 111 112 |
# File 'lib/zephira/agent.rb', line 110 def run_command(name:, args:) @commands.run(name: name, args: args, agent: self) end |
#run_inference_with_spinner(messages) ⇒ Object
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/zephira/agent.rb', line 242 def run_inference_with_spinner() response = nil spinner_format_string = Formatter.color(:grey, "[") + Formatter.color(:green, " :spinner ") + Formatter.color(:grey, ":elapsed] ") @spinner = TTY::Spinner.new(spinner_format_string, format: :dots) spinner_started_at = Time.now @spinner.on(:spin) do elapsed = (Time.now - spinner_started_at).to_i @spinner.update(elapsed: sprintf("%03ds", elapsed)) end @spinner.run(Formatter.color(:green, "Done!")) do response = @model.inference( api_key: Config.read("ZEPHIRA_API_KEY"), base_url: Config.read("ZEPHIRA_BASE_URL"), messages: , agent: self ) end @spinner = nil response end |
#run_loop ⇒ Object
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/zephira/agent.rb', line 138 def run_loop Readline.completion_proc = proc { |input| @completions.complete_all(input: input, agent: self) } seed_readline_history print_intro loop do user_input = Readline.readline("> ", true) break if user_input.nil? input = user_input.strip next if input.empty? TTY::Cursor.hide echo_user_input(input) if input.start_with?("/") dispatch_command(input) TTY::Cursor.show next end (input) TTY::Cursor.show history. compact_if_needed rescue Interrupt puts "\n[Interrupted]" break rescue => e puts "\nError: #{e.}" logger.error("#{e.class}: #{e.}\n#{e.backtrace.first(5).join("\n")}") end end |
#run_tool(name:, args:) ⇒ Object
106 107 108 |
# File 'lib/zephira/agent.rb', line 106 def run_tool(name:, args:) @tools.run(name: name, args: args, agent: self) end |
#seed_readline_history ⇒ Object
175 176 177 178 179 180 181 |
# File 'lib/zephira/agent.rb', line 175 def seed_readline_history return unless history.session_start > 0 history.[0...history.session_start] .select { || [:role] == "user" } .map { || [:content] } .each { |command| Readline::HISTORY.push(command) } end |
#thinking(model_class) ⇒ Object
93 94 95 96 97 |
# File 'lib/zephira/agent.rb', line 93 def thinking(model_class) thinkmojis = %w[🤔 🧠 💭 🤯 🧐 ⏳ 🔄 🌀 🤨 💡 🧩 🔍 📚 ⚙️] token_count = Tokens.estimate(history..to_json) update_status("Thinking... #{thinkmojis.shuffle.first} " + Formatter.color(:grey, "(#{model_class.model_name} - #{token_count} tokens)")) end |
#update_status(msg) ⇒ Object
99 100 101 102 103 104 |
# File 'lib/zephira/agent.rb', line 99 def update_status(msg) @output_mutex.synchronize do @spinner&.spin puts msg end end |