Module: RobotLab::ScriptTool

Defined in:
lib/robot_lab/script_tool.rb

Overview

Factory module for wrapping AgentSkills scripts as RobotLab::Tool instances.

Given a path to an executable shell script, produces a Tool that shells out to the script and returns its combined stdout+stderr output. Non-executable scripts return nil with a logged warning.

Class Method Summary collapse

Class Method Details

.derive_name(path) ⇒ String

Returns snake_case tool name derived from filename.

Parameters:

  • path (Pathname)

Returns:

  • (String)

    snake_case tool name derived from filename



109
110
111
112
113
114
# File 'lib/robot_lab/script_tool.rb', line 109

def self.derive_name(path)
  path.basename.to_s
      .sub(/\.[^.]+$/, '')
      .gsub(/[^a-zA-Z0-9]+/, '_')
      .gsub(/^_+|_+$/, '')
end

.execute(cmd, capabilities:, skill_dir:) ⇒ String

Run a command, optionally confined by the sandbox and a timeout.

When sandboxing is disabled (the default) this is the original, unconfined capture2e path with no timeout — behaviour is unchanged. When enabled, the command is wrapped by the sandbox strategy for the effective grant and bounded by the grant's timeout.

Returns:

  • (String)

    combined stdout+stderr, or an error string on failure



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/robot_lab/script_tool.rb', line 63

def self.execute(cmd, capabilities:, skill_dir:)
  unless Sandbox.enabled?
    output, status = Open3.capture2e(*cmd)
    return format_result(output, status)
  end

  grant   = capabilities.intersect(Capabilities.ceiling)
  sandbox = Sandbox.for(grant, skill_dir: skill_dir)
  begin
    output, status = run_with_timeout(sandbox.wrap(cmd), grant.timeout)
    format_result(output, status)
  ensure
    sandbox.cleanup
  end
end

.extract_description(path) ⇒ String

Extract tool description from the first non-shebang comment line.

Parameters:

  • path (Pathname)

Returns:

  • (String)


120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/robot_lab/script_tool.rb', line 120

def self.extract_description(path)
  File.foreach(path) do |line|
    stripped = line.strip
    next unless stripped.start_with?('#')
    next if stripped.start_with?('#!') # skip shebang

    desc = stripped.sub(/^#+\s*/, '').strip
    return desc unless desc.empty?
  end
  derive_name(path)
rescue StandardError
  derive_name(path)
end

.format_result(output, status) ⇒ Object

Parameters:

  • status (Process::Status, nil)

    nil indicates a timeout kill



101
102
103
104
105
# File 'lib/robot_lab/script_tool.rb', line 101

def self.format_result(output, status)
  return "Error (timed out):\n#{output}" if status.nil?

  status.success? ? output : "Error (exit #{status.exitstatus}):\n#{output}"
end

.from_path(script_path, capabilities: nil, skill_dir: nil) ⇒ RobotLab::Tool?

Wrap a script file as a RobotLab::Tool.

Parameters:

  • script_path (String, Pathname)

    path to the script file

  • script_path (String, Pathname)

    path to the script file

  • capabilities (Capabilities, nil) (defaults to: nil)

    declared capabilities (from SKILL.md)

  • skill_dir (String, nil) (defaults to: nil)

    skill bundle root (defaults to the script's dir)

Returns:



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/robot_lab/script_tool.rb', line 22

def self.from_path(script_path, capabilities: nil, skill_dir: nil)
  path = Pathname.new(script_path)

  unless path.executable?
    RobotLab.config.logger.warn(
      "ScriptTool: #{path.basename} is not executable, skipping"
    )
    return nil
  end

  tool_name   = derive_name(path)
  description = extract_description(path)
  script      = path.to_s
  caps        = capabilities || Capabilities.new
  dir         = skill_dir || path.dirname.to_s

  Tool.create(
    name: tool_name,
    description: description,
    parameters: {
      type: 'object',
      properties: {
        args: { type: 'string', description: 'Optional command-line arguments' }
      },
      required: []
    }
  ) do |tool_args|
    cli_args = tool_args[:args].to_s.strip
    cmd      = cli_args.empty? ? ['bash', script] : ['bash', script, *Shellwords.split(cli_args)]
    ScriptTool.execute(cmd, capabilities: caps, skill_dir: dir)
  end
end

.run_with_timeout(cmd, timeout) ⇒ Array(String, Process::Status|nil)

Returns output and status (nil = timed out).

Returns:

  • (Array(String, Process::Status|nil))

    output and status (nil = timed out)



80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/robot_lab/script_tool.rb', line 80

def self.run_with_timeout(cmd, timeout)
  Open3.popen2e(*cmd, pgroup: true) do |stdin, out, wait|
    stdin.close
    output = +''
    begin
      Timeout.timeout(timeout) { output << out.read }
    rescue Timeout::Error
      terminate(wait.pid)
      return ["#{output}\n[killed: exceeded #{timeout}s]", nil]
    end
    [output, wait.value]
  end
end

.terminate(pid) ⇒ Object



94
95
96
97
98
# File 'lib/robot_lab/script_tool.rb', line 94

def self.terminate(pid)
  Process.kill('-TERM', Process.getpgid(pid))
rescue StandardError
  nil
end