robot_lab-to 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.loki +21 -0
  5. data/.quality/reek_baseline.txt +17 -0
  6. data/.rubocop.yml +7 -0
  7. data/CHANGELOG.md +67 -0
  8. data/CLAUDE.md +74 -0
  9. data/COMMITS.md +27 -0
  10. data/README.md +352 -0
  11. data/Rakefile +132 -0
  12. data/bin/robot-to +14 -0
  13. data/docs/about/changelog.md +82 -0
  14. data/docs/assets/architecture.svg +110 -0
  15. data/docs/assets/images/robot_lab-to.jpg +0 -0
  16. data/docs/assets/iteration-loop.svg +97 -0
  17. data/docs/concepts/evals.md +254 -0
  18. data/docs/concepts/iteration-loop.md +128 -0
  19. data/docs/concepts/notes.md +91 -0
  20. data/docs/concepts/run-state.md +93 -0
  21. data/docs/concepts/stop-conditions.md +111 -0
  22. data/docs/concepts/verification.md +75 -0
  23. data/docs/configuration/cli.md +111 -0
  24. data/docs/configuration/index.md +93 -0
  25. data/docs/configuration/settings.md +270 -0
  26. data/docs/getting-started/anatomy-of-a-run.md +111 -0
  27. data/docs/getting-started/installation.md +78 -0
  28. data/docs/getting-started/quick-start.md +105 -0
  29. data/docs/index.md +96 -0
  30. data/docs/local-models/guardrails.md +117 -0
  31. data/docs/local-models/index.md +88 -0
  32. data/docs/local-models/ollama.md +122 -0
  33. data/docs/local-models/tools.md +92 -0
  34. data/docs/reference/architecture.md +139 -0
  35. data/examples/01_basic_usage/.gitignore +9 -0
  36. data/examples/01_basic_usage/README.md +173 -0
  37. data/examples/01_basic_usage/basic_usage.rb +386 -0
  38. data/examples/02_advanced_usage/.gitignore +8 -0
  39. data/examples/02_advanced_usage/README.md +117 -0
  40. data/examples/02_advanced_usage/advanced_usage.rb +308 -0
  41. data/examples/02_advanced_usage/quality_gate.rb +99 -0
  42. data/examples/03_scored/.gitignore +2 -0
  43. data/examples/03_scored/README.md +117 -0
  44. data/examples/03_scored/scored_run.rb +325 -0
  45. data/examples/04_prose/.gitignore +2 -0
  46. data/examples/04_prose/README.md +43 -0
  47. data/examples/04_prose/prompts_dir/grade_message.md +14 -0
  48. data/examples/04_prose/prompts_dir/judge.md +11 -0
  49. data/examples/04_prose/prompts_dir/outline_criteria.md +9 -0
  50. data/examples/04_prose/prompts_dir/outline_objective.md +11 -0
  51. data/examples/04_prose/prompts_dir/section_criteria.md +7 -0
  52. data/examples/04_prose/prompts_dir/sections_objective.md +14 -0
  53. data/examples/04_prose/prose_run.rb +302 -0
  54. data/lib/robot_lab/to/atomic_file.rb +69 -0
  55. data/lib/robot_lab/to/backoff.rb +46 -0
  56. data/lib/robot_lab/to/cli.rb +176 -0
  57. data/lib/robot_lab/to/commit_manager.rb +135 -0
  58. data/lib/robot_lab/to/config/defaults.yml +27 -0
  59. data/lib/robot_lab/to/config.rb +86 -0
  60. data/lib/robot_lab/to/decision.rb +40 -0
  61. data/lib/robot_lab/to/decision_manager.rb +193 -0
  62. data/lib/robot_lab/to/errors.rb +30 -0
  63. data/lib/robot_lab/to/evals/base.rb +23 -0
  64. data/lib/robot_lab/to/evals/code.rb +77 -0
  65. data/lib/robot_lab/to/evals/context.rb +17 -0
  66. data/lib/robot_lab/to/evals/factory.rb +84 -0
  67. data/lib/robot_lab/to/evals/null.rb +17 -0
  68. data/lib/robot_lab/to/evals/prose.rb +110 -0
  69. data/lib/robot_lab/to/evals/score.rb +22 -0
  70. data/lib/robot_lab/to/exit_summary.rb +75 -0
  71. data/lib/robot_lab/to/guards/checkpoint.rb +84 -0
  72. data/lib/robot_lab/to/guards/grader_lock.rb +62 -0
  73. data/lib/robot_lab/to/guards/path_resolution.rb +61 -0
  74. data/lib/robot_lab/to/guards/quality_monitor.rb +113 -0
  75. data/lib/robot_lab/to/guards/read_before_edit.rb +83 -0
  76. data/lib/robot_lab/to/guards/run_store.rb +31 -0
  77. data/lib/robot_lab/to/guards/write_guard.rb +71 -0
  78. data/lib/robot_lab/to/guards.rb +54 -0
  79. data/lib/robot_lab/to/iteration_result.rb +29 -0
  80. data/lib/robot_lab/to/jsonl_logger.rb +59 -0
  81. data/lib/robot_lab/to/notes_manager.rb +121 -0
  82. data/lib/robot_lab/to/orchestrator.rb +642 -0
  83. data/lib/robot_lab/to/prompt_builder.rb +208 -0
  84. data/lib/robot_lab/to/run.rb +109 -0
  85. data/lib/robot_lab/to/stop_conditions.rb +71 -0
  86. data/lib/robot_lab/to/tools/bash.rb +75 -0
  87. data/lib/robot_lab/to/tools/edit.rb +52 -0
  88. data/lib/robot_lab/to/tools/file_tool.rb +32 -0
  89. data/lib/robot_lab/to/tools/read.rb +57 -0
  90. data/lib/robot_lab/to/tools/request_decision.rb +66 -0
  91. data/lib/robot_lab/to/tools/submit_result.rb +50 -0
  92. data/lib/robot_lab/to/tools/write.rb +32 -0
  93. data/lib/robot_lab/to/verifier.rb +63 -0
  94. data/lib/robot_lab/to/version.rb +7 -0
  95. data/lib/robot_lab/to.rb +81 -0
  96. data/mkdocs.yml +145 -0
  97. metadata +175 -0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_resolution"
4
+ require_relative "run_store"
5
+
6
+ module RobotLab
7
+ module To
8
+ module Guards
9
+ # Read-before-edit invariant, ported from little-coder read-guard-edit.
10
+ #
11
+ # Small models fire Edit with an oldText they never actually saw, guessing
12
+ # the file's contents — which either fails the exact-match (wasted turn) or
13
+ # matches the wrong span (silent corruption). We block any Edit whose
14
+ # target wasn't Read this run.
15
+ #
16
+ # The read-set spans many tool calls within one run, so it lives in the
17
+ # per-run RunStore (see that file for why ctx.local can't hold it). A
18
+ # successful Read, Edit, or Write marks the path as known.
19
+ class ReadBeforeEdit < RobotLab::Hook
20
+ self.namespace = :read_before_edit
21
+
22
+ KEY = :robot_lab_to_read_files
23
+ TRACKED_READS = %w[read edit write].freeze
24
+
25
+ class << self
26
+ # Fresh run = clean slate; a prior iteration's reads say nothing here.
27
+ def before_run(_ctx)
28
+ RunStore.reset(KEY, [])
29
+ end
30
+
31
+ # Record successful reads/authored files as "known".
32
+ def after_tool_call(ctx)
33
+ return if ctx.tool_error
34
+ return unless TRACKED_READS.include?(ctx.tool_name.to_s.downcase)
35
+
36
+ path = PathResolution.resolve(ctx.tool_args)
37
+ mark_read(path) if path
38
+ end
39
+
40
+ # Block edits to files that were never read.
41
+ def around_tool_call(ctx)
42
+ return yield unless ctx.tool_name.to_s.casecmp?("edit")
43
+
44
+ path = PathResolution.resolve(ctx.tool_args)
45
+ if path && !read?(path)
46
+ ctx.tool_result = edit_before_read_reason(path)
47
+ return ctx.tool_result
48
+ end
49
+
50
+ yield
51
+ end
52
+
53
+ # @return [Boolean] whether `path` has been read this run
54
+ def read?(path)
55
+ read_files.include?(path)
56
+ end
57
+
58
+ def mark_read(path)
59
+ files = read_files
60
+ files << path unless files.include?(path)
61
+ end
62
+
63
+ # @return [Array<String>] the per-run read-set (live reference)
64
+ def read_files
65
+ RunStore.fetch(KEY, [])
66
+ end
67
+
68
+ # @param resolved [String]
69
+ # @return [String]
70
+ def edit_before_read_reason(resolved)
71
+ <<~MSG
72
+ File must be read before edit — #{resolved} has not been read in
73
+ this session. Read #{resolved} first to get the exact current text
74
+ for oldText (whitespace and indentation must match exactly), then
75
+ issue the Edit. Include 2-3 surrounding lines so oldText is unique.
76
+ Do NOT guess the file's contents.
77
+ MSG
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ module Guards
6
+ # Per-run shared state for the guards.
7
+ #
8
+ # RobotLab creates a fresh tool-call HookContext per call with its own
9
+ # ExtensionState, so cross-call guard state (the read-set, checkpoint-set,
10
+ # loop tracker) cannot live in ctx.local. robot_lab-to runs each iteration
11
+ # in its own thread (Orchestrator#run_robot_with_interrupt), so a
12
+ # thread-local is a clean per-run scope — the same idiom robot_lab-audit
13
+ # uses for run ids. before_run resets it; the thread ends with the
14
+ # iteration, so nothing leaks across runs.
15
+ module RunStore
16
+ module_function
17
+
18
+ # Set/replace the value for `key`.
19
+ def reset(key, value)
20
+ Thread.current[key] = value
21
+ end
22
+
23
+ # Fetch the value for `key`, initializing to `default` when unset.
24
+ def fetch(key, default = nil)
25
+ Thread.current[key] = default if Thread.current[key].nil? && !default.nil?
26
+ Thread.current[key]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_resolution"
4
+
5
+ module RobotLab
6
+ module To
7
+ module Guards
8
+ # Refuses Write on a file that already exists, redirecting the model to
9
+ # Edit, and normalizes root-bare paths to cwd. Ported from little-coder
10
+ # write-guard: small models rewrite whole files and destroy content; the
11
+ # whitepaper notes this refusal fires on ~57% of Polyglot exercises.
12
+ #
13
+ # Tool names matched (case-insensitive): "write".
14
+ class WriteGuard < RobotLab::Hook
15
+ self.namespace = :write_guard
16
+
17
+ WRITE_TOOLS = %w[write].freeze
18
+
19
+ class << self
20
+ # Normalize the path in place before the tool runs (applies even when
21
+ # we don't block — e.g. the "/foo.md" -> cwd rewrite).
22
+ def before_tool_call(ctx)
23
+ return unless write_tool?(ctx.tool_name)
24
+
25
+ args = ctx.tool_args
26
+ resolved = PathResolution.resolve(args)
27
+ return unless resolved
28
+
29
+ key = PathResolution::PATH_KEYS.find { |k| args.key?(k) || args.key?(k.to_sym) }
30
+ args[key] = resolved if key
31
+ end
32
+
33
+ # Block the write when the target already exists.
34
+ def around_tool_call(ctx)
35
+ unless write_tool?(ctx.tool_name)
36
+ return yield
37
+ end
38
+
39
+ resolved = PathResolution.resolve(ctx.tool_args)
40
+ if resolved && File.exist?(resolved)
41
+ ctx.tool_result = edit_recipe(resolved)
42
+ return ctx.tool_result
43
+ end
44
+
45
+ yield
46
+ end
47
+
48
+ # @param tool_name [String]
49
+ # @return [Boolean]
50
+ def write_tool?(tool_name)
51
+ WRITE_TOOLS.include?(tool_name.to_s.downcase)
52
+ end
53
+
54
+ # The message returned to the model in place of the refused write.
55
+ #
56
+ # @param resolved [String]
57
+ # @return [String]
58
+ def edit_recipe(resolved)
59
+ <<~MSG
60
+ Write refused — #{resolved} already exists. Write is for creating
61
+ NEW files only. To change an existing file, Read it first to get the
62
+ exact current text, then use Edit with that text as oldText
63
+ (whitespace and indentation must match). Do NOT retry Write; it will
64
+ be refused again.
65
+ MSG
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "guards/run_store"
4
+ require_relative "guards/path_resolution"
5
+ require_relative "guards/write_guard"
6
+ require_relative "guards/read_before_edit"
7
+ require_relative "guards/checkpoint"
8
+ require_relative "guards/quality_monitor"
9
+ require_relative "guards/grader_lock"
10
+
11
+ module RobotLab
12
+ module To
13
+ # Small-model guardrails ported from little-coder (Apache-2.0).
14
+ #
15
+ # These RobotLab::Hook subclasses make file/bash tools safe for a small
16
+ # *local* model driving an autonomous robot_lab-to loop. They are no-ops
17
+ # for a frontier model, so registration is opt-in.
18
+ #
19
+ # Wire them onto a per-iteration robot in Orchestrator#build_robot:
20
+ #
21
+ # robot = RobotLab.build(...)
22
+ # RobotLab::To::Guards.install(robot, run: @run) if @config.local_guards
23
+ # robot
24
+ #
25
+ # Each guard is single-purpose and independently testable: its class methods
26
+ # take a HookContext and have no hidden global state beyond a per-robot store
27
+ # that is reset on before_run.
28
+ module Guards
29
+ ALL = [
30
+ Guards::WriteGuard,
31
+ Guards::ReadBeforeEdit,
32
+ Guards::Checkpoint,
33
+ Guards::QualityMonitor
34
+ ].freeze
35
+
36
+ # Register every guard on a robot.
37
+ #
38
+ # @param robot [RobotLab::Robot]
39
+ # @param run [RobotLab::To::Run, nil] supplies run_dir for checkpoints
40
+ # @return [RobotLab::Robot] the same robot, for chaining
41
+ # `except:` lists guard classes to skip. Passing [WriteGuard] lets a robot
42
+ # overwrite existing files (WriteGuard otherwise refuses Write on an existing
43
+ # file and redirects to Edit -- unhelpful for iterative prose that rewrites a
44
+ # draft).
45
+ def self.install(robot, run: nil, except: [])
46
+ active = ALL - Array(except)
47
+ robot.on(Guards::Checkpoint, context: { run: run }) if run
48
+ (active - [Guards::Checkpoint]).each { |guard| robot.on(guard) }
49
+ robot.on(Guards::Checkpoint) unless run
50
+ robot
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ IterationResult = Data.define(
6
+ :success, # Boolean
7
+ :summary, # String
8
+ :key_changes, # Array<String>
9
+ :key_learnings, # Array<String>
10
+ :should_fully_stop # Boolean or nil (nil when --stop-when not active)
11
+ ) do
12
+ def success? = success
13
+ def stop? = !!should_fully_stop
14
+
15
+ # Used when --stop-when is not configured
16
+ def self.no_stop_when(success:, summary:, key_changes: [], key_learnings: [])
17
+ new(success: success, summary: summary,
18
+ key_changes: key_changes, key_learnings: key_learnings,
19
+ should_fully_stop: nil)
20
+ end
21
+
22
+ # Sentinel result when the robot returns without calling submit_iteration_result
23
+ def self.not_submitted
24
+ new(success: false, summary: "Robot did not call submit_iteration_result",
25
+ key_changes: [], key_learnings: [], should_fully_stop: nil)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RobotLab
6
+ module To
7
+ # Writes structured JSONL events to the run log file.
8
+ #
9
+ # Events emitted before the log file is ready are buffered in memory
10
+ # and flushed on first open (capacity: 100 lines).
11
+ class JsonlLogger
12
+ MAX_BUFFER = 100
13
+
14
+ def initialize
15
+ @path = nil
16
+ @buffer = []
17
+ @file = nil
18
+ end
19
+
20
+ def open(path)
21
+ @path = path
22
+ @file = File.open(path, "a")
23
+ @file.sync = true
24
+ flush_buffer
25
+ end
26
+
27
+ def log(event, **payload)
28
+ entry = { event: event, ts: Time.now.iso8601(3) }.merge(payload).compact
29
+ line = JSON.generate(entry)
30
+ if @file
31
+ @file.write("#{line}\n") # single write keeps the line intact on crash
32
+ else
33
+ @buffer.shift if @buffer.size >= MAX_BUFFER
34
+ @buffer << line
35
+ end
36
+ end
37
+
38
+ def close
39
+ if @file
40
+ @file.flush
41
+ begin
42
+ @file.fsync
43
+ rescue SystemCallError
44
+ nil
45
+ end
46
+ @file.close
47
+ end
48
+ @file = nil
49
+ end
50
+
51
+ private
52
+
53
+ def flush_buffer
54
+ @buffer.each { |line| @file.write("#{line}\n") }
55
+ @buffer.clear
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module To
5
+ # Manages the cross-iteration notes file (notes.md).
6
+ #
7
+ # Written by the orchestrator only. The robot reads it at the start of every
8
+ # iteration to understand prior work.
9
+ class NotesManager
10
+ attr_reader :path
11
+
12
+ def initialize(path)
13
+ @path = Pathname.new(path)
14
+ end
15
+
16
+ def setup(run)
17
+ AtomicFile.write(@path, <<~HEADER)
18
+ # robot-to run: #{run.run_id}
19
+
20
+ Objective: #{run.objective}
21
+
22
+ ## Iteration Log
23
+ HEADER
24
+ end
25
+
26
+ def read
27
+ @path.exist? ? @path.read : ""
28
+ end
29
+
30
+ def append_success(result, iteration)
31
+ changes_md = bullet_list(result.key_changes)
32
+ learnings_md = bullet_list(result.key_learnings)
33
+ append(<<~MD)
34
+
35
+ ### Iteration #{iteration}
36
+
37
+ **Summary:** #{result.summary}
38
+
39
+ **Changes:**
40
+ #{changes_md}
41
+ **Learnings:**
42
+ #{learnings_md}
43
+ MD
44
+ end
45
+
46
+ def append_failure(result, iteration)
47
+ learnings_md = bullet_list(result.key_learnings)
48
+ append(<<~MD)
49
+
50
+ ### Iteration #{iteration} [FAIL]
51
+
52
+ **Summary:** #{result.summary}
53
+
54
+ **Learnings:**
55
+ #{learnings_md}
56
+ MD
57
+ end
58
+
59
+ def append_no_improvement(result, detail, iteration)
60
+ append(<<~MD)
61
+
62
+ ### Iteration #{iteration} [NO IMPROVEMENT]
63
+
64
+ **Summary:** #{result.summary}
65
+
66
+ **Score:** #{detail}
67
+
68
+ The change passed the gate but did not beat the parent commit, so it was
69
+ rolled back. Try a different approach next iteration.
70
+ MD
71
+ end
72
+
73
+ def append_verify_failure(result, output, iteration)
74
+ append(<<~MD)
75
+
76
+ ### Iteration #{iteration} [VERIFY FAILED]
77
+
78
+ **Summary:** #{result.summary}
79
+
80
+ **Verification output:**
81
+ ```
82
+ #{output}
83
+ ```
84
+ MD
85
+ end
86
+
87
+ def append_error(error, iteration)
88
+ append(<<~MD)
89
+
90
+ ### Iteration #{iteration} [ERROR]
91
+
92
+ **Error:** #{error.class}: #{error.message}
93
+ MD
94
+ end
95
+
96
+ def append_decision(decision, iteration)
97
+ append(<<~MD)
98
+
99
+ ### Iteration #{iteration} [DECISION]
100
+
101
+ **Raised:** #{decision.question}
102
+ **Blocking:** #{decision.blocking? ? "yes" : "no"}
103
+ **Recommendation:** #{decision.recommendation}
104
+ **File:** #{decision.path}
105
+ MD
106
+ end
107
+
108
+ private
109
+
110
+ def append(text)
111
+ AtomicFile.append(@path, text)
112
+ end
113
+
114
+ def bullet_list(items)
115
+ return "- (none)\n" if items.nil? || items.empty?
116
+
117
+ items.map { |i| "- #{i}" }.join("\n") + "\n"
118
+ end
119
+ end
120
+ end
121
+ end