diff --git a/dsl/simple_agent.rb b/dsl/simple_agent.rb index 19c7a07b..b882abd7 100644 --- a/dsl/simple_agent.rb +++ b/dsl/simple_agent.rb @@ -6,9 +6,12 @@ config do agent do provider :claude + model "haiku" + initial_prompt "Always respond in haiku form" + show_prompt! end end execute do - agent(:foo) { "Say hi" } + agent { "What is the world's largest lake?" } end diff --git a/lib/roast/dsl/cogs/agent.rb b/lib/roast/dsl/cogs/agent.rb index a97e92a3..9f1f93cf 100644 --- a/lib/roast/dsl/cogs/agent.rb +++ b/lib/roast/dsl/cogs/agent.rb @@ -12,13 +12,348 @@ class MissingPromptError < AgentCogError; end class Config < Cog::Config VALID_PROVIDERS = [:claude].freeze #: Array[Symbol] - field :provider, :claude do |provider| + + # Configure the cog to use a specified provider when invoking an agent + # + # The provider is the source of the agent tool itself. + # If no provider is specified, Anthropic Claude Code (`:claude`) will be used as the default provider. + # + # A provider must be properly installed on your system in order for Roast to be able to use it. + # + # #### See Also + # - `use_default_provider!` + # - `valid_provider!` + # + #: (Symbol) -> void + def provider(provider) + @values[:provider] = provider + end + + # Configure the cog to use the default provider when invoking an agent + # + # The default provider used by Roast is Anthropic Claude Code (`:claude`). + # + # The provider must be properly installed on your system in order for Roast to be able to use it. + # + # #### See Also + # - `provider` + # - `valid_provider!` + # + #: () -> void + def use_default_provider! + @values[:provider] = nil + end + + # Get the validated provider name that the cog is configured to use when invoking an agent + # + # Note: this method will return the name of a valid provider or raise an `InvalidConfigError`. + # It will __not__, however, validate that the agent is properly installed on your system. + # If the agent is not properly installed, you will likely experience a failure when Roast attempts to + # run your workflow. + # + # #### See Also + # - `provider` + # - `use_default_provider!` + # + #: () -> Symbol + def valid_provider! + provider = @values[:provider] || VALID_PROVIDERS.first unless VALID_PROVIDERS.include?(provider) raise ArgumentError, "'#{provider}' is not a valid provider. Available providers include: #{VALID_PROVIDERS.join(", ")}" end provider end + + # Configure the cog to use a specific model when invoking the agent + # + # The model name format is provider-specific. + # + # #### See Also + # - `use_default_model!` + # - `valid_model` + # + #: (String) -> void + def model(model) + @values[:model] = model + end + + # Configure the cog to use the provider's default model when invoking the agent + # + # Note: the default model will be different for different providers. + # + # #### See Also + # - `model` + # + #: () -> void + def use_default_model! + @values[:model] = nil + end + + # Get the validated, configured value of the model the cog is configured to use when running the agent + # + # `nil` means that the provider should use its own default model, however that is configured. + # + #: () -> String? + def valid_model + @values[:model].presence + end + + # Configure the cog with an initial prompt component that will be appended to the agent's system prompt + # every time the agent is invoked + # + # #### See Also + # - `no_initial_prompt!` + # - `valid_initial_prompt` + # + #: (String) -> void + def initial_prompt(prompt) + @values[:initial_prompt] = prompt + end + + # Configure the cog __not__ to append an initial prompt to the agent's system prompt when the agent is invoked + # + # #### See Also + # - `initial_prompt` + # - `valid_initial_prompt` + # + #: () -> void + def no_initial_prompt! + @values[:initial_prompt] = "" + end + + # Get the validated, configured initial prompt that will be appended to the agent's system prompt when + # the agent is invoked + # + # This value will be `nil` if __no__ prompt should be appended. + # + # #### See Also + # - `initial_prompt` + # - `no_initial_prompt!` + # + #: () -> String? + def valid_initial_prompt + @values[:initial_prompt].presence + end + + # Configure the cog to apply the default set of system and user permissions when running the agent + # + # How these permissions are defined and configured is specific to the agent provider being used. + # + # The cog's default behaviour is to run with __no__ permissions. + # + # #### Alias Methods + # - `apply_permissions!` + # - `no_skip_permissions!` + # + # #### Inverse Methods + # - `no_apply_permissions!` + # - `skip_permissions!` + # + #: () -> void + def apply_permissions! + @values[:apply_permissions] = true + end + + # Configure the cog to run the agent with __no__ permissions applied + # + # The cog's default behaviour is to run with __no__ permissions. + # + # #### Alias Methods + # - `no_apply_permissions!` + # - `skip_permissions!` + # + # #### Inverse Methods + # - `apply_permissions!` + # - `no_skip_permissions!` + # + #: () -> void + def no_apply_permissions! + @values[:apply_permissions] = false + end + + # Check if the cog is configured to apply permissions when running the agent + # + # #### See Also + # - `apply_permissions!` + # - `no_apply_permissions!` + # - `skip_permissions!` + # - `no_skip_permissions!` + # + #: () -> bool + def apply_permissions? + !!@values[:apply_permissions] + end + + # Configure the cog to display the prompt when running the agent + # + # Disabled by default. + # + # #### See Also + # - `no_show_prompt!` + # - `show_prompt?` + # - `display!` + # + #: () -> void + def show_prompt! + @values[:show_prompt] = true + end + + # Configure the cog __not__ to display the prompt when running the agent + # + # This is the default behaviour. + # + # #### See Also + # - `show_prompt!` + # - `show_prompt?` + # - `no_display!` + # + #: () -> void + def no_show_prompt! + @values[:show_prompt] = false + end + + # Check if the cog is configured to display the prompt when running the agent + # + # #### See Also + # - `show_prompt!` + # - `no_show_prompt!` + # + #: () -> bool + def show_prompt? + @values.fetch(:show_prompt, false) + end + + # Configure the cog to display the agent's in-progress messages when running + # + # This includes thinking blocks and other intermediate output from the agent. + # Enabled by default. + # + # #### See Also + # - `no_show_progress!` + # - `show_progress?` + # - `display!` + # + #: () -> void + def show_progress! + @values[:show_progress] = true + end + + # Configure the cog __not__ to display the agent's in-progress messages when running + # + # This will hide thinking blocks and other intermediate output from the agent. + # + # #### See Also + # - `show_progress!` + # - `show_progress?` + # - `no_display!` + # + #: () -> void + def no_show_progress! + @values[:show_progress] = false + end + + # Check if the cog is configured to display the agent's in-progress messages when running + # + # #### See Also + # - `show_progress!` + # - `no_show_progress!` + # + #: () -> bool + def show_progress? + @values.fetch(:show_progress, true) + end + + # Configure the cog to display the agent's final response + # + # Enabled by default. + # + # #### See Also + # - `no_show_response!` + # - `show_response?` + # - `display!` + # + #: () -> void + def show_response! + @values[:show_response] = true + end + + # Configure the cog __not__ to display the agent's final response + # + # #### See Also + # - `show_response!` + # - `show_response?` + # - `no_display!` + # + #: () -> void + def no_show_response! + @values[:show_response] = false + end + + # Check if the cog is configured to display the agent's final response + # + # #### See Also + # - `show_response!` + # - `no_show_response!` + # + #: () -> bool + def show_response? + @values.fetch(:show_response, true) + end + + # Configure the cog to display all agent output + # + # This enables `show_prompt!`, `show_progress!`, and `show_response!`. + # + # #### See Also + # - `no_display!` + # - `show_prompt!` + # - `show_progress!` + # - `show_response!` + # + #: () -> void + def display! + show_prompt! + show_progress! + show_response! + end + + # Configure the cog to __hide__ all agent output + # + # This enables `no_show_prompt!`, `no_show_progress!`, and `no_show_response!`. + # + # #### Alias Methods + # - `no_display!` + # - `quiet!` + # + # #### See Also + # - `display!` + # - `no_show_prompt!` + # - `no_show_progress!` + # - `no_show_response!` + # + #: () -> void + def no_display! + no_show_prompt! + no_show_progress! + no_show_response! + end + + # Check if the cog is configured to display any output while running + # + # #### See Also + # - `show_prompt?` + # - `show_progress?` + # - `show_response?` + # + #: () -> bool + def display? + show_prompt? || show_progress? || show_response? + end + + alias_method(:skip_permissions!, :no_apply_permissions!) + alias_method(:no_skip_permissions!, :apply_permissions!) + alias_method(:quiet!, :no_display!) end class Input < Cog::Input @@ -54,12 +389,6 @@ def valid_prompt! class Output < Cog::Output #: String attr_reader :response - - #: (String response) -> void - def initialize(response) - super() - @response = response - end end #: Agent::Config @@ -67,20 +396,21 @@ def initialize(response) #: (Input) -> Output def execute(input) - response = provider.invoke(input.valid_prompt!) - puts "[AGENT RESPONSE] #{response}" - Output.new(response) + puts "[USER PROMPT] #{input.valid_prompt!}" if config.show_prompt? + output = provider.invoke(input) + puts "[AGENT RESPONSE] #{output.response}" if config.show_response? + output end private - #: () -> Providers::Base + #: () -> Provider def provider - @provider ||= case config.provider + @provider ||= case config.valid_provider! when :claude - Providers::Claude.new + Providers::Claude.new(config) else - raise UnknownProviderError, "Unknown provider: #{config.provider}" + raise UnknownProviderError, "Unknown provider: #{config.valid_provider!}" end end end diff --git a/lib/roast/dsl/cogs/agent/provider.rb b/lib/roast/dsl/cogs/agent/provider.rb new file mode 100644 index 00000000..d19d0b53 --- /dev/null +++ b/lib/roast/dsl/cogs/agent/provider.rb @@ -0,0 +1,24 @@ +# typed: true +# frozen_string_literal: true + +module Roast + module DSL + module Cogs + class Agent < Cog + # Abstract parent class for implementations of a Provider for the Agent cog + class Provider + #: (Config) -> void + def initialize(config) + super() + @config = config + end + + #: (Input) -> Output + def invoke(input) + raise NotImplementedError, "Subclasses must implement #invoke" + end + end + end + end + end +end diff --git a/lib/roast/dsl/cogs/agent/providers/base.rb b/lib/roast/dsl/cogs/agent/providers/base.rb deleted file mode 100644 index 840053bc..00000000 --- a/lib/roast/dsl/cogs/agent/providers/base.rb +++ /dev/null @@ -1,19 +0,0 @@ -# typed: true -# frozen_string_literal: true - -module Roast - module DSL - module Cogs - class Agent < Cog - module Providers - class Base - #: (String) -> String - def invoke(prompt) - raise NotImplementedError, "Subclasses must implement #invoke" - end - end - end - end - end - end -end diff --git a/lib/roast/dsl/cogs/agent/providers/claude.rb b/lib/roast/dsl/cogs/agent/providers/claude.rb index 9c569704..33b7ad5c 100644 --- a/lib/roast/dsl/cogs/agent/providers/claude.rb +++ b/lib/roast/dsl/cogs/agent/providers/claude.rb @@ -6,17 +6,22 @@ module DSL module Cogs class Agent < Cog module Providers - class Claude < Base - #: (String) -> String - def invoke(prompt) - # Use CmdRunner to execute claude CLI - stdout, stderr, status = Roast::Helpers::CmdRunner.capture3("claude", "-p", prompt) + class Claude < Provider + class Output < Agent::Output + delegate :response, to: :@invocation_result - unless status&.success? - raise "Claude command failed: #{stderr}" + #: (ClaudeInvocation::Result) -> void + def initialize(invocation_result) + super() + @invocation_result = invocation_result end + end - stdout&.strip || "" + #: (Agent::Input) -> Agent::Output + def invoke(input) + invocation = ClaudeInvocation.new(@config, input) + invocation.run! + Output.new(invocation.result) end end end diff --git a/lib/roast/dsl/cogs/agent/providers/claude/claude_invocation.rb b/lib/roast/dsl/cogs/agent/providers/claude/claude_invocation.rb new file mode 100644 index 00000000..1f5fcef7 --- /dev/null +++ b/lib/roast/dsl/cogs/agent/providers/claude/claude_invocation.rb @@ -0,0 +1,105 @@ +# typed: true +# frozen_string_literal: true + +module Roast + module DSL + module Cogs + class Agent < Cog + module Providers + class Claude < Provider + class ClaudeInvocation + class ClaudeInvocationError < Roast::Error; end + + class ClaudeNotStartedError < ClaudeInvocationError; end + + class ClaudeAlreadyStartedError < ClaudeInvocationError; end + + class ClaudeNotCompletedError < ClaudeInvocationError; end + + class ClaudeFailedError < ClaudeInvocationError; end + + class Result + #: String + attr_accessor :response + end + + #: (Agent::Config, Agent::Input) -> void + def initialize(config, input) + @model = config.valid_model + @append_system_prompt = config.valid_initial_prompt + @apply_permissions = config.apply_permissions? + @working_directory = config.valid_working_directory + @prompt = input.valid_prompt! + end + + #: () -> void + def run! + raise ClaudeAlreadyStartedError if started? + + @started = true + puts "Running Claude: #{command_line}" + puts "Providing Standard Input: #{@prompt}" + + stdout, stderr, status = CommandRunner.execute( + command_line, + working_directory: @working_directory, + stdin_content: @prompt, + ) + + unless status.success? + raise "Claude command failed: #{stderr}" + end + + @result = Result.new + @result.response = stdout.strip + rescue StandardError => e + @failed = true + raise e + end + + #: () -> bool + def started? + @started ||= false + end + + #: () -> bool + def running? + @started && !@failed && @result.present? + end + + #: () -> bool + def completed? + @result.present? + end + + #: () -> bool + def failed? + @failed ||= false + end + + #: () -> Result + def result + raise ClaudeNotStartedError unless started? + raise ClaudeFailedError if failed? + raise ClaudeNotCompletedError unless @result.present? + + @result + end + + private + + #: () -> Array[String] + def command_line + command = ["claude", "-p"] + command << "--model" << @model if @model.present? + command << "--append-system-prompt" << @append_system_prompt if @append_system_prompt + command << "--dangerously-skip-permissions" unless @apply_permissions + command + end + end + end + end + end + end + end +end diff --git a/sorbet/rbi/shims/lib/roast/dsl/cogs/agent.rbi b/sorbet/rbi/shims/lib/roast/dsl/cogs/agent.rbi deleted file mode 100644 index 8cc21118..00000000 --- a/sorbet/rbi/shims/lib/roast/dsl/cogs/agent.rbi +++ /dev/null @@ -1,19 +0,0 @@ -# typed: true -# frozen_string_literal: true - -module Roast - module DSL - module Cogs - class Agent - class Config - #: (Symbol) -> Symbol - #: () -> Symbol - def provider(value); end - - #: () -> Symbol - def use_default_provider!; end - end - end - end - end -end diff --git a/test/dsl/functional/roast_dsl_examples_test.rb b/test/dsl/functional/roast_dsl_examples_test.rb index 7436fadb..e7d4a882 100644 --- a/test/dsl/functional/roast_dsl_examples_test.rb +++ b/test/dsl/functional/roast_dsl_examples_test.rb @@ -202,15 +202,31 @@ class RoastDSLExamplesTest < FunctionalTest mock_status = mock mock_status.expects(:success?).returns(true).at_least_once - Roast::Helpers::CmdRunner.stubs(:capture3) - .with("claude", "-p", "Say hi") - .returns(["Hi! How can I help you today?", "", mock_status]) + Roast::DSL::CommandRunner.expects(:execute) + .with( + [ + "claude", + "-p", + "--model", + "haiku", + "--append-system-prompt", + "Always respond in haiku form", + "--dangerously-skip-permissions", + ], + working_directory: nil, + stdin_content: "What is the world's largest lake?", + ) + .returns([ + "Caspian Sea sits,\nThough called sea, it's landlocked, vast -\nWorld's largest true lake.", + "", + mock_status, + ]) stdout, stderr = in_sandbox :simple_agent do Roast::DSL::Workflow.from_file("dsl/simple_agent.rb", EMPTY_PARAMS) end - assert_includes stdout, "Hi! How can I help you today?" + assert_includes stdout, "Caspian Sea sits,\nThough called sea, it's landlocked, vast -\nWorld's largest true lake." assert_empty stderr end