Class: Zephira::Agent

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initializeAgent

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.expand_path("tools", __dir__)]
  command_dirs = [File.expand_path("commands", __dir__)]
  completion_dirs = [File.expand_path("completions", __dir__)]

  @tools = Tools.load(paths: tool_dirs)
  @commands = Commands.load(paths: command_dirs)
  @completions = Completions.load(paths: completion_dirs)
  @history = History.new
  @history.compact_tool_messages!

  @uname = `uname -a`.strip
  @pwd = `pwd`.strip

  @model = resolve_model
end

Instance Attribute Details

#commandsObject (readonly)

Returns the value of attribute commands.



66
67
68
# File 'lib/zephira/agent.rb', line 66

def commands
  @commands
end

#completionsObject (readonly)

Returns the value of attribute completions.



66
67
68
# File 'lib/zephira/agent.rb', line 66

def completions
  @completions
end

#historyObject (readonly)

Returns the value of attribute history.



66
67
68
# File 'lib/zephira/agent.rb', line 66

def history
  @history
end

#loggerObject (readonly)

Returns the value of attribute logger.



66
67
68
# File 'lib/zephira/agent.rb', line 66

def logger
  @logger
end

#modelObject

Returns the value of attribute model.



67
68
69
# File 'lib/zephira/agent.rb', line 67

def model
  @model
end

#statusObject (readonly)

Returns the value of attribute status.



66
67
68
# File 'lib/zephira/agent.rb', line 66

def status
  @status
end

#toolsObject (readonly)

Returns the value of attribute tools.



66
67
68
# File 'lib/zephira/agent.rb', line 66

def tools
  @tools
end

#verboseObject

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.messages))
  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.messages.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.messages))
  puts Formatter.color(:grey, "  ✦ History compacted (~#{after} tokens).")
  true
end

#compact_if_neededObject



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


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 process_user_message(input)
  history.append(role: "user", content: input)
  messages = [system_prompt] + history.messages.map { |message| message.slice(:role, :content, :tool_call_id, :tool_calls) }

  response = run_inference_with_spinner(messages)

  if response
    history.append(role: "assistant", content: response)
    puts Formatter.color(:grey, "\nZephira:")
    puts Formatter.format(response, indent: 2)
    puts
  end
end

#render_status_barObject



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/zephira/agent.rb', line 196

def render_status_bar
  context_used = Tokens.estimate(JSON.dump(@history.messages))
  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(messages)
  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: messages,
      agent: self
    )
  end
  @spinner = nil
  response
end

#run_loopObject



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
    render_status_bar

    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

    process_user_message(input)
    TTY::Cursor.show

    history.compact_tool_messages!
    compact_if_needed
  rescue Interrupt
    puts "\n[Interrupted]"
    break
  rescue => e
    puts "\nError: #{e.message}"
    logger.error("#{e.class}: #{e.message}\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_historyObject



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.messages[0...history.session_start]
    .select { |message| message[:role] == "user" }
    .map { |message| message[: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.messages.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