Class: Legate::CLI::AgentCommands

Inherits:
BaseCommand show all
Includes:
OutputHelper
Defined in:
lib/legate/cli/agent_commands.rb

Overview

CLI commands for agent definition management AND temporary execution

Constant Summary collapse

@@session_service_for_execute =

In-memory session service backing the ‘execute` command. Overridable in tests.

Legate::SessionService::InMemory.new

Class Method Summary collapse

Instance Method Summary collapse

Methods included from OutputHelper

#output_error, #output_result, #status_message

Methods inherited from BaseCommand

#tree

Class Method Details

.exit_on_failure?Boolean

— END CHAT COMMAND —

Returns:

  • (Boolean)


945
946
947
# File 'lib/legate/cli/agent_commands.rb', line 945

def self.exit_on_failure?
  true
end

Instance Method Details

#ai_generateObject



411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
# File 'lib/legate/cli/agent_commands.rb', line 411

def ai_generate
  require_relative '../generators'

  description = nil
  from_stdin = false

  # Priority: --description > --prompt-file > stdin
  if options[:description] && !options[:description].strip.empty?
    description = options[:description].strip
  elsif options[:prompt_file]
    unless File.exist?(options[:prompt_file])
      say "Error: Prompt file '#{options[:prompt_file]}' not found.", :red
      exit(1)
    end
    description = File.read(options[:prompt_file]).strip
  elsif !$stdin.tty?
    # Reading from stdin (piped input)
    description = $stdin.read.strip
    from_stdin = true
  end

  if description.nil? || description.empty?
    say 'Error: No description provided. Use --description, --prompt-file, or pipe via stdin.', :red
    exit(1)
  end

  # Determine output mode
  output_to_stdout = options[:stdout] || from_stdin

  say 'Generating agent code via AI...', :cyan unless output_to_stdout

  begin
    result = Legate::Generators::AgentGenerator.generate(description: description)
    code = result[:code]
    suggested_name = result[:suggested_name]

    if output_to_stdout
      puts code
    else
      # Write to file
      file_path = options[:output] || "./#{suggested_name}_agent.rb"

      if File.exist?(file_path) && !options[:force] && !yes?("File '#{file_path}' already exists. Overwrite? [y/N]", :yellow)
        say 'Generation cancelled.', :yellow
        exit(0)
      end

      File.write(file_path, code)
      say "Agent definition generated and saved to '#{file_path}'", :green
      say "  Suggested name: #{suggested_name}", :cyan
    end
  rescue Legate::Generators::AgentGenerator::ApiKeyMissingError => e
    say "Error: #{e.message}", :red
    exit(1)
  rescue Legate::Generators::AgentGenerator::ApiError => e
    say "Error: #{e.message}", :red
    exit(1)
  rescue Legate::Generators::AgentGenerator::GenerationError => e
    say "Error: #{e.message}", :red
    exit(1)
  rescue StandardError => e
    say "Unexpected error: #{e.class} - #{e.message}", :red
    exit(1)
  end
end

#chat(agent_name_str) ⇒ Object



818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
# File 'lib/legate/cli/agent_commands.rb', line 818

def chat(agent_name_str)
  ::CLI::UI::StdoutRouter.enable
  agent_name_sym = agent_name_str.to_sym

  agent_definition_object = Legate::GlobalDefinitionRegistry.find(agent_name_sym)
  unless agent_definition_object
    ::CLI::UI.puts "{{red:Error: Agent definition '#{agent_name_str}' not found.}}"
    exit(1)
  end

  # Convert AgentDefinition to hash for display/compatibility
  definition = Legate::GlobalDefinitionRegistry.get_definition(agent_name_sym)

  session_service_instance = Legate::SessionService::InMemory.new

  current_session_id = options[:session_id]
  legate_session = nil # Will hold the loaded Legate::Session object

  ::CLI::UI::Frame.open("Chat Session with #{agent_name_str}", color: :blue) do
    ::CLI::UI.puts "{{bold:Agent Description:}} #{definition[:description]}"
    if current_session_id
      legate_session = session_service_instance.get_session(session_id: current_session_id)
      if legate_session
        ::CLI::UI.puts "{{green:Resuming session:}} #{current_session_id} (in-memory)"
        # --- MODIFIED: Display history if session is loaded and has events ---
        if legate_session.events && !legate_session.events.empty?
          ::CLI::UI.puts "\n{{bold:━━━ Recent Conversation History ━━━}}"

          # Group events by conversation turns
          history_events = legate_session.events.last(20) # Show more history items
          current_date = nil

          history_events.each do |event|
            # Extract timestamp from event and format it
            event_time = event.timestamp ? Time.at(event.timestamp) : Time.now
            formatted_time = event_time.strftime('%H:%M:%S')

            # Show date separator if this is a new day
            event_date = event_time.strftime('%Y-%m-%d')
            if current_date != event_date
              current_date = event_date
              ::CLI::UI.puts "\n{{bold:┅┅┅ #{event_time.strftime('%B %d, %Y')} ┅┅┅}}"
            end

            if event.role == :user
              # For user role, event.content is the string message
              _format_chat_turn_output_cli_ui(event.content, :user, formatted_time)
            elsif event.role == :agent
              # For agent role, event.content is the hash {status:, result:, ...}
              _format_chat_turn_output_cli_ui(event.content, :agent, formatted_time)
            end
            # Tool events are generally not shown in simple chat history
          end
          ::CLI::UI.puts "{{bold:━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━}}\n"
        else
          ::CLI::UI.puts '{{italic:No previous messages in this session.}}'
        end
        # --- END MODIFICATION ---
      else
        ::CLI::UI.puts "{{yellow:Warning: Session ID '#{current_session_id}' not found. Starting new session.}}"
        current_session_id = nil # Force new session creation
      end
    end
    unless legate_session # If still no session (either not provided, or provided but not found)
      legate_session = session_service_instance.create_session(app_name: agent_name_str, user_id: options[:user_id])
      current_session_id = legate_session.id # Update current_session_id with the new one
      ::CLI::UI.puts "{{green:Started new session:}} #{current_session_id} (in-memory)"
    end
    ::CLI::UI.puts "{{gray:Type 'exit' or 'quit' to end the chat.}}"
  end
  ::CLI::UI.puts '---'

  agent = nil
  begin
    # Instantiate the agent with its definition object and the session service
    agent = Legate::Agent.new(
      definition: agent_definition_object,
      session_service: session_service_instance
    )
    # Tool setup is now handled within Legate::Agent#initialize based on the definition object.

    agent.start
  rescue StandardError => e
    ::CLI::UI.puts "{{red:Error initializing or starting agent: #{e.message}}}"
    exit(1)
  end

  loop do
    user_input = ::CLI::UI::Prompt.ask('You')
    break if user_input.nil?

    user_input.strip!
    break if %w[exit quit].include?(user_input.downcase)
    next if user_input.empty?

    final_event = nil
    session_lost_flag = false
    begin
      ::CLI::UI::Spinner.spin('Agent thinking...') do |_spinner|
        current_legate_session_for_task = session_service_instance.get_session(session_id: current_session_id)
        unless current_legate_session_for_task
          ::CLI::UI.puts "{{red:Error: Session '#{current_session_id}' lost. Please restart chat.}}"
          session_lost_flag = true
          break
        end

        final_event = agent.run_task(
          session_id: current_session_id,
          user_input: user_input,
          session_service: session_service_instance
        )
      end
      break if session_lost_flag

      _format_chat_turn_output_cli_ui(final_event)
    rescue StandardError => e
      ::CLI::UI::Frame.open('Error During Task', color: :red) do
        ::CLI::UI.puts e.message
      end
    end
  end
ensure
  agent&.stop if agent&.running?
  ::CLI::UI.puts '{{yellow:Chat ended.}}'
end

#delete(name) ⇒ Object



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/legate/cli/agent_commands.rb', line 284

def delete(name)
  name_sym = name.to_sym
  definition_exists = Legate::GlobalDefinitionRegistry.definition_exists?(name_sym)

  unless definition_exists
    say "Error: Agent definition '#{name}' not found.", :red
    exit(1)
  end

  if yes?("Are you sure you want to permanently delete agent definition '#{name}'? [y/N]", :yellow)
    Legate::GlobalDefinitionRegistry.delete_definition(name_sym)
    say "Agent definition '#{name}' deleted successfully.", :green
  else
    say 'Deletion cancelled.', :yellow
  end
end

#execute(name, task) ⇒ Object



725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
# File 'lib/legate/cli/agent_commands.rb', line 725

def execute(name, task)
  # Suppress all logging in JSON mode for clean output
  Legate.logger.level = Logger::FATAL if json_mode?

  name_sym = name.to_sym
  status_message("Loading agent '#{name}' to execute task: \"#{task}\"...")
  agent_definition_object = Legate::GlobalDefinitionRegistry.find(name_sym)

  unless agent_definition_object
    output_error("Agent definition '#{name}' not found.", metadata: { agent: name })
    exit(1)
  end

  session_service_instance = @@session_service_for_execute
  session_id_opt = options[:session_id]
  legate_session = nil
  if session_id_opt
    legate_session = session_service_instance.get_session(session_id: session_id_opt)
    if legate_session then status_message("Continuing session: #{session_id_opt}", :cyan)
    else
      status_message("Warning: Session ID '#{session_id_opt}' provided but not found. Starting a new session.", :yellow)
      session_id_opt = nil
    end
  end
  unless legate_session
    legate_session = session_service_instance.create_session(app_name: name, user_id: options[:user_id])
    session_id_opt = legate_session.id
    status_message("Started new session: #{session_id_opt}", :cyan)
    status_message('  (Using in-memory session storage)', :cyan)
  end

  agent = nil
  e_outer = nil
  begin
    # Pass the definition object. Session service for the agent instance itself will use global default
    # or the one passed if Legate::Agent.new supported it directly for its own session_service attr.
    # The run_task method will use the session_service_instance passed to it for actual session operations.
    agent = Legate::Agent.new(
      definition: agent_definition_object,
      session_service: session_service_instance
    )

    status_message("  - Agent uses model: #{agent.model_name}", :cyan)

    # Tool loading is now handled by Legate::Agent#initialize via the definition.
    loaded_tool_instances = agent.tools
    loaded_tool_names = loaded_tool_instances.map(&:name)
    defined_tool_names = agent_definition_object.tool_names.to_a
    missing_tools = defined_tool_names - loaded_tool_names

    status_message("  - Warning: Tools defined but not loaded/found: [#{missing_tools.join(', ')}]", :yellow) unless missing_tools.empty?
    status_message("  - Loaded tools: [#{loaded_tool_names.join(', ')}]", :cyan)
    status_message('  - Starting agent runtime...', :cyan)
    agent.start
    status_message('started.', :cyan)
    status_message("  - Running task in session #{session_id_opt}: '#{task}'...", :cyan)
    final_event_or_error = agent.run_task(
      session_id: session_id_opt,
      user_input: task,
      session_service: session_service_instance
    )
    status_message('finished.', :cyan)
    status_message("\nTask Result:", :bold)
    output_result(final_event_or_error, metadata: { session_id: session_id_opt, agent: name }, format_method: :format_cli_result)
  rescue StandardError => e
    e_outer = e
    output_error(e, metadata: { agent: name, session_id: session_id_opt })
    puts e.backtrace.first(5).join("\n") unless json_mode?
  ensure
    if agent&.running?
      status_message('  - Stopping agent runtime...', :cyan)
      agent.stop
      status_message('stopped.', :cyan)
    end
    exit(1) if e_outer
  end
end

#export(name) ⇒ Object



667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
# File 'lib/legate/cli/agent_commands.rb', line 667

def export(name)
  name_sym = name.to_sym

  # Load definition from registry
  definition = Legate::GlobalDefinitionRegistry.get_definition(name_sym)

  unless definition
    say "Error: Agent definition '#{name}' not found.", :red
    exit(1)
  end

  # Clean up internal fields before export
  export_data = definition.dup
  export_data.delete(:persistent_status) # implementation detail
  # Ensure keys are strings for cleaner JSON/YAML
  export_data = export_data.transform_keys(&:to_s)

  # Format output
  output_content = ''
  case options[:format].downcase
  when 'json'
    output_content = JSON.pretty_generate(export_data)
  when 'yaml'
    output_content = export_data.to_yaml
  end

  # Write to file or stdout
  if options[:output]
    begin
      File.write(options[:output], output_content)
      say "Agent definition exported to #{options[:output]}", :green
    rescue StandardError => e
      say "Error writing to file: #{e.message}", :red
      exit(1)
    end
  else
    say output_content
  end
end

#generate(name) ⇒ Object



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/legate/cli/agent_commands.rb', line 309

def generate(name)
  agent_name_sym = name.to_sym
  dir_path = File.expand_path(options[:dir])
  file_path = File.join(dir_path, "#{name}_agent.rb")
  if File.exist?(file_path) && !options[:force] && !yes?("Agent file '#{file_path}' already exists. Overwrite? [y/N]", :yellow)
    say 'Generation cancelled.', :yellow
    exit(0)
  end
  begin
    FileUtils.mkdir_p(dir_path)
  rescue SystemCallError => e
    say "Error: Could not create directory '#{dir_path}': #{e.message}", :red
    exit(1)
  end
  agent_name_str = name
  description = options[:description]
  instruction = options[:instruction]
  tools_list = options[:tools].split(',').map(&:strip).reject(&:empty?).map(&:to_sym)
  model_str = options[:model]
  webhook_enabled = options[:webhook_enabled]
  code = <<~RUBY
    require 'legate'

    Legate::Agent.define do |a|
      a.name :#{agent_name_sym}
      a.description "#{description}"
      a.instruction "#{instruction}"
    #{'  '}
  RUBY
  if model_str && !model_str.empty?
    code += "  # Optional: Specify model (defaults to Legate.config.default_model_name)\n"
    code += "  a.model_name '#{model_str}'\n\n"
  else
    code += "  # Model will use framework default: #{Legate.config.default_model_name}\n\n"
  end
  code += "  # Define tools the agent can use\n"
  if tools_list.empty?
    code += "  # a.use_tool :echo # Example\n"
  else
    tools_list.each { |tool| code += "  a.use_tool :#{tool}\n" }
  end
  code += "\n"
  if webhook_enabled
    code += <<~WEBHOOK
      # --- Webhook Configuration ---#{' '}
      # This agent can be triggered by POST /webhooks/agents/#{agent_name_sym}/trigger
      # (Assuming default listener base_path and dynamic_agent_route_pattern in Legate.configure)

      a.webhook_enabled true

      a.webhook_transformer ->(request_body) do#{' '}
        raise NotImplementedError, "Please implement the webhook_transformer proc to convert request_body into agent user_input."
      end

      a.webhook_session_extractor ->(request_body) do
        raise NotImplementedError, "Please implement the webhook_session_extractor proc to extract a session ID."
      end
    WEBHOOK
  end
  code += "end\n"
  begin
    File.write(file_path, code)
    say "Agent definition file created at '#{file_path}'", :green
    if webhook_enabled
      say "\nWebhook configuration placeholders added. Please implement the required transformer and extractor procs.", :yellow
      say 'Remember to configure validation and secrets for production use!', :yellow
    end
  rescue SystemCallError => e
    say "Error: Could not write file '#{file_path}': #{e.message}", :red
    exit(1)
  end
end

#listObject



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/legate/cli/agent_commands.rb', line 207

def list
  definitions = Legate::GlobalDefinitionRegistry.all
  if json_mode?
    agents = definitions.sort_by { |name, _| name.to_s }.map do |name, defn|
      {
        name: name.to_s,
        description: defn.description || nil,
        model: (defn.model_name || Legate::Agent::DEFAULT_MODEL).to_s,
        tools: defn.tool_names.to_a.map(&:to_s)
      }
    end
    puts JSON.generate({ agents: agents })
  elsif definitions.empty?
    say 'No agent definitions found.'
  else
    say 'Defined Agents:', :bold
    definitions.sort_by { |name, _| name.to_s }.each do |name, defn|
      description = defn.description || '[No description]'
      tools = defn.tool_names.to_a
      model = (defn.model_name || "#{Legate::Agent::DEFAULT_MODEL} (Default)").to_s
      tools_str = tools.empty? ? 'None' : tools.join(', ')
      say "- #{name}: #{description} (Model: #{model}, Tools: #{tools_str})"
    end
  end
end

#save(name) ⇒ Object



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/legate/cli/agent_commands.rb', line 242

def save(name)
  name_sym = name.to_sym
  description = options[:description]
  model_to_save = options[:model] && !options[:model].empty? ? options[:model] : Legate::Agent::DEFAULT_MODEL
  instruction_to_save = options[:instruction]

  selected_tools = []
  valid_tools = Legate::GlobalToolManager.registered_tool_names.map(&:to_s)
  if options[:tools]
    requested_tools = options[:tools].split(',').map(&:strip).reject(&:empty?)
    requested_tools.each do |tool_name|
      if valid_tools.include?(tool_name)
        selected_tools << tool_name unless selected_tools.include?(tool_name)
      else
        say "Warning: Unknown globally registered tool '#{tool_name}', ignoring.", :yellow
      end
    end
  end

  definition = {
    description: description,
    tools: selected_tools,
    model: model_to_save,
    instruction: instruction_to_save,
    fallback_mode: :error,
    mcp_servers_json: options[:mcp_servers_json] || '[]',
    webhook_enabled: options[:webhook_enabled],
    webhook_secret: options[:webhook_secret]
  }

  # Build an AgentDefinition object and register it
  agent_def = Legate::AgentDefinition.from_hash(definition.merge(name: name_sym))
  unless agent_def
    say 'Error creating agent definition. Aborting.', :red
    exit(1)
  end
  Legate::GlobalDefinitionRegistry.register(agent_def)
  tools_msg = selected_tools.empty? ? 'None' : selected_tools.join(', ')
  say "Agent definition '#{name}' saved (Model: #{model_to_save}, Tools: #{tools_msg}, Instruction: #{instruction_to_save ? 'Set' : 'Not Set'}).", :green
end

#start(name) ⇒ Object



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
# File 'lib/legate/cli/agent_commands.rb', line 488

def start(name)
  # Suppress all logging in JSON mode for clean output
  Legate.logger.level = Logger::FATAL if json_mode?

  name_sym = name.to_sym
  status_message("Loading agent '#{name}'...")

  # First check the global registry
  agent_definition_object = Legate::GlobalDefinitionRegistry.find(name_sym)

  unless agent_definition_object
    output_error("Agent definition '#{name}' not found.", metadata: { agent: name })
    exit(1)
  end

  agent = nil
  result_data = nil
  begin
    # Pass the definition object directly. Session service will use global default.
    agent = Legate::Agent.new(definition: agent_definition_object)

    # Tool loading is now handled by Legate::Agent#initialize via the definition.
    loaded_tool_names = agent.tools.map(&:name)
    defined_tool_names = agent_definition_object.tool_names.to_a
    missing_tools = defined_tool_names - loaded_tool_names

    status_message("  - Agent uses model: #{agent.model_name}", :cyan)
    status_message("  - Agent instruction: #{agent.instruction.inspect}", :cyan)
    status_message("  - Warning: Tools defined but not loaded/found: [#{missing_tools.join(', ')}]", :yellow) unless missing_tools.empty?
    status_message("  - Loaded tools: [#{loaded_tool_names.join(', ')}]", :cyan)

    status_message('  - Starting agent runtime...', :cyan)
    agent.start
    status_message('started.', :cyan)
    status_message("\nAgent '#{name}' is ready.", :green)

    result_data = {
      status: 'ready',
      agent: name,
      model: agent.model_name,
      tools: loaded_tool_names.map(&:to_s),
      missing_tools: missing_tools.map(&:to_s)
    }
  rescue StandardError => e
    output_error(e, metadata: { agent: name })
    puts e.backtrace.first(5).join("\n") unless json_mode?
    exit(1)
  ensure
    if agent&.running?
      status_message('  - Stopping agent runtime...', :cyan)
      agent.stop
      status_message('stopped.', :cyan)
    end
  end

  # Output final result in JSON mode
  return unless json_mode? && result_data

  puts JSON.generate(result_data)
end

#status(name) ⇒ Object



620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
# File 'lib/legate/cli/agent_commands.rb', line 620

def status(name)
  # Suppress all logging in JSON mode for clean output
  Legate.logger.level = Logger::FATAL if json_mode?

  name_sym = name.to_sym
  status_message("Checking status of agent '#{name}'...")

  # Load definition from registry
  definition = Legate::GlobalDefinitionRegistry.get_definition(name_sym)

  unless definition
    output_error("Agent definition '#{name}' not found.", metadata: { agent: name })
    exit(1)
  end

  persistent_status = definition[:persistent_status] || 'stopped'
  model = definition[:model] || Legate::Agent::DEFAULT_MODEL
  description = definition[:description] || '[No description]'
  tools = definition[:tools] || []

  if json_mode?
    puts JSON.generate({
                         agent: name,
                         status: persistent_status,
                         model: model,
                         description: description,
                         tools: tools
                       })
  else
    say "Agent: #{name}", :bold
    case persistent_status
    when 'running'
      say "  Status: #{persistent_status}", :green
    when 'stopped'
      say "  Status: #{persistent_status}", :yellow
    else
      say "  Status: #{persistent_status}", :cyan
    end
    say "  Model: #{model}"
    say "  Description: #{description}"
    say "  Tools: #{tools.empty? ? 'None' : tools.join(', ')}"
  end
end

#stop(name) ⇒ Object



565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
# File 'lib/legate/cli/agent_commands.rb', line 565

def stop(name)
  # Suppress all logging in JSON mode for clean output
  Legate.logger.level = Logger::FATAL if json_mode?

  name_sym = name.to_sym
  status_message("Stopping agent '#{name}'...")

  # Load definition from registry
  definition = Legate::GlobalDefinitionRegistry.get_definition(name_sym)

  unless definition
    output_error("Agent definition '#{name}' not found.", metadata: { agent: name })
    exit(1)
  end

  # Check current status
  current_status = definition[:persistent_status] || 'stopped'
  if current_status == 'stopped'
    if json_mode?
      puts JSON.generate({ status: 'already_stopped', agent: name })
    else
      say "Agent '#{name}' is already stopped.", :yellow
    end
    return
  end

  # Confirm if not forced (skip in quiet/json mode)
  if !(options[:force] || quiet_mode?) && !yes?("Agent '#{name}' is currently marked as '#{current_status}'. Stop it? [y/N]", :yellow)
    say 'Stop cancelled.', :yellow
    return
  end

  # Update the persistent_status to stopped
  Legate::GlobalDefinitionRegistry.update_definition(name_sym, { persistent_status: 'stopped' })

  if json_mode?
    puts JSON.generate({ status: 'stopped', agent: name, previous_status: current_status })
  else
    say "Agent '#{name}' has been marked as stopped.", :green
    say '  Note: If running in a web server, the agent will stop on next status check or server restart.', :cyan
  end
end