lyman 0.2.0 → 0.3.0

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.
@@ -6,34 +6,37 @@ module Lyman
6
6
  extend Shifty::DSL
7
7
 
8
8
  # Relay worker: executes any tool calls pending on the conversation and
9
- # appends their results. Acts only when the turn isn't already finished
10
- # and the model has asked for tools; otherwise passes through.
9
+ # hands off a new conversation with their results appended. Acts only
10
+ # when the turn isn't already finished and the model has asked for
11
+ # tools; otherwise passes through.
11
12
  #
12
13
  # +handlers+ is a hash of tool name => callable taking a Hash of
13
14
  # arguments and returning something stringable.
14
15
  def self.tool_execution(handlers)
15
16
  relay_worker do |conversation|
16
- unless conversation.finished?
17
- conversation.pending_tool_calls.each do |tool_call|
18
- name = tool_call.dig("function", "name")
19
- args = parse_arguments(tool_call.dig("function", "arguments"))
20
- handler = handlers[name]
17
+ # Fold results onto the accumulator while iterating the *original*
18
+ # conversation's pending calls: each with_tool_result appends a
19
+ # tool message, which empties pending_tool_calls on the new value.
20
+ conversation.pending_tool_calls.reduce(conversation) do |convo, tool_call|
21
+ break convo if convo.finished?
21
22
 
22
- result =
23
- if handler
24
- begin
25
- handler.call(args).to_s
26
- rescue => e
27
- "Tool #{name} raised #{e.class}: #{e.message}"
28
- end
29
- else
30
- "Unknown tool: #{name}"
23
+ name = tool_call.dig("function", "name")
24
+ args = parse_arguments(tool_call.dig("function", "arguments"))
25
+ handler = handlers[name]
26
+
27
+ result =
28
+ if handler
29
+ begin
30
+ handler.call(args).to_s
31
+ rescue => e
32
+ "Tool #{name} raised #{e.class}: #{e.message}"
31
33
  end
34
+ else
35
+ "Unknown tool: #{name}"
36
+ end
32
37
 
33
- conversation.add_tool_result(tool_call["id"], result)
34
- end
38
+ convo.with_tool_result(tool_call["id"], result)
35
39
  end
36
- conversation
37
40
  end
38
41
  end
39
42
 
data/templates/CLAUDE.md CHANGED
@@ -2,17 +2,36 @@
2
2
 
3
3
  This project was scaffolded by [lyman](https://github.com/joelhelbling/lyman):
4
4
  an agentic harness built on the [shifty](https://github.com/joelhelbling/shifty)
5
- gem's pipeline-of-workers model. `harness/chat.rb` wires a model⇄tool loop
5
+ gem's pipeline-of-workers model. `harness/repl.rb` wires a model⇄tool loop
6
6
  against any OpenAI-compatible chat completions endpoint (Ollama by default).
7
7
 
8
8
  ## Running it
9
9
 
10
- - `bundle install` — install dependencies (just `shifty` and `ostruct`; this
11
- project has no runtime dependency on the `lyman` gem itself).
12
- - `ruby harness/chat.rb` run the interactive chat harness. Defaults to
10
+ - `bundle install` — install dependencies (`shifty`, `ostruct`, and `cli-ui`
11
+ for the harness's display layer; this project has no runtime dependency on
12
+ the `lyman` gem itself).
13
+ - `ruby harness/repl.rb` — run the interactive repl harness. Defaults to
13
14
  Ollama at `http://localhost:11434/v1`; override with `LYMAN_MODEL` and
14
15
  `LYMAN_BASE_URL` env vars.
15
16
 
17
+ ## Harness archetypes
18
+
19
+ Every lyman harness is the same model⇄tool circuit inside a different
20
+ *shell* (state + a driving process). Three archetypes cover the shell
21
+ shapes — see the [Harness Archetypes wiki page](https://github.com/joelhelbling/lyman/wiki/Harness-Archetypes):
22
+
23
+ - **REPL** (`harness/repl.rb`, planted by default) — a human drives the
24
+ loop and ends it. One conversation accretes across turns.
25
+ - **Daemon** (`lyman add daemon_harness`) — launch once, loop indefinitely
26
+ on an inbound event stream; no human in the loop. Fresh conversation per
27
+ event.
28
+ - **Script** (`lyman add script_harness`) — launched by cron or on demand
29
+ with its work item in hand; processes it and halts. No loop in the shell
30
+ at all.
31
+
32
+ To build a new harness, start from the archetype whose shell shape matches
33
+ — the circuit rarely needs to change; the supplier of work items does.
34
+
16
35
  ## The managed/owned boundary
17
36
 
18
37
  Everything under the `Lyman::` namespace (`lib/lyman.rb`, `lib/lyman/`) is
@@ -23,27 +42,34 @@ which takes ownership explicitly rather than leaving it silently forked.
23
42
  `.lyman/manifest.yml` is the record of what's managed, what's owned, and what
24
43
  you've ejected; commit it.
25
44
 
26
- `harness/chat.rb` and everything outside `Lyman::` is **owned** — yours from
27
- day one, never touched by `lyman update`. Put your own workers in your own
28
- namespace or directory, not inside `lib/lyman/`.
45
+ The harness scripts (`harness/*.rb`) and everything outside `Lyman::` are
46
+ **owned** — yours from day one, never touched by `lyman update`. Put your own
47
+ workers in your own namespace or directory, not inside `lib/lyman/`.
48
+
49
+ ## Five load-bearing facts
29
50
 
30
- ## Four load-bearing facts
51
+ 1. **Frozen handoffs.** Shifty (0.6+) deep-freezes every value at a worker
52
+ boundary; a task that mutates its input raises `Shifty::PolicyViolation`.
53
+ `Conversation` is an immutable value: express change with its `with_*`
54
+ methods (a new conversation comes back) and rebind shell state to what
55
+ the pipeline returns — never mutate in place. Closure state inside a
56
+ worker stays freely mutable; only handed-off values freeze.
31
57
 
32
- 1. **The nil-source footgun.** In shifty, a source returning `nil` ends the
58
+ 2. **The nil-source footgun.** In shifty, a source returning `nil` ends the
33
59
  stream permanently. If you're driving a pipeline off a queue, enqueue
34
60
  before you shift — never pull a source worker while its queue is empty.
35
61
 
36
- 2. **The runaway-turn guard.** The model⇄tool circuit is bounded by
62
+ 3. **The runaway-turn guard.** The model⇄tool circuit is bounded by
37
63
  `Conversation#runaway?` / `max_rounds`. Keep that guard intact when
38
64
  rewiring the circuit; without it, a model that keeps calling tools never
39
65
  lets a turn end.
40
66
 
41
- 3. **Item-as-control discipline.** An item may tell a worker *whether* to
67
+ 4. **Item-as-control discipline.** An item may tell a worker *whether* to
42
68
  act, never *which of several things* to do. A worker that switches
43
69
  between jobs based on item state is the anti-pattern to avoid
44
70
  (multi-way dispatch) — split it into stages, or use a splitter, instead.
45
71
 
46
- 4. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
72
+ 5. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
47
73
  the `Conversation` for observability, but `Workers.wire_messages` strips
48
74
  it before anything goes out over the wire. Preserve that separation if
49
75
  you touch message handling.
data/templates/Gemfile CHANGED
@@ -1,4 +1,7 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "shifty"
3
+ gem "shifty", "~> 0.6" # handoff immutability: values are frozen at worker boundaries
4
4
  gem "ostruct" # shifty dependency; no longer a default gem as of ruby 4.0
5
+ # Used only by harness/repl.rb's display layer; drop them if you restyle.
6
+ gem "cli-ui"
7
+ gem "reline"
data/templates/SKILL.md CHANGED
@@ -1,23 +1,41 @@
1
1
  ---
2
2
  name: lyman
3
- description: Guidance for working in a lyman-scaffolded agentic harness — the pipeline-of-workers model, the managed/owned boundary, and lyman's sharp edges. Use when reading or changing the harness (harness/chat.rb), anything under lib/lyman/, or when running lyman CLI commands (add, update, eject, diff, doctor).
3
+ description: Guidance for working in a lyman-scaffolded agentic harness — the pipeline-of-workers model, the harness archetypes, the managed/owned boundary, and lyman's sharp edges. Use when reading or changing a harness script (harness/*.rb), anything under lib/lyman/, or when running lyman CLI commands (add, update, eject, diff, doctor).
4
4
  ---
5
5
 
6
6
  # Working in a lyman project
7
7
 
8
8
  This project was scaffolded by [lyman](https://github.com/joelhelbling/lyman):
9
9
  an agentic harness built on the [shifty](https://github.com/joelhelbling/shifty)
10
- gem's pipeline-of-workers model. `harness/chat.rb` wires a model⇄tool loop
10
+ gem's pipeline-of-workers model. `harness/repl.rb` wires a model⇄tool loop
11
11
  against any OpenAI-compatible chat completions endpoint (Ollama by default).
12
12
 
13
13
  ## Running it
14
14
 
15
15
  - `bundle install` — install dependencies (just `shifty` and `ostruct`; this
16
16
  project has no runtime dependency on the `lyman` gem itself).
17
- - `ruby harness/chat.rb` — run the interactive chat harness. Defaults to
17
+ - `ruby harness/repl.rb` — run the interactive repl harness. Defaults to
18
18
  Ollama at `http://localhost:11434/v1`; override with `LYMAN_MODEL` and
19
19
  `LYMAN_BASE_URL` env vars.
20
20
 
21
+ ## Harness archetypes
22
+
23
+ Every lyman harness is the same model⇄tool circuit inside a different
24
+ *shell* (state + a driving process). Three archetypes cover the shell
25
+ shapes — see the [Harness Archetypes wiki page](https://github.com/joelhelbling/lyman/wiki/Harness-Archetypes):
26
+
27
+ - **REPL** (`harness/repl.rb`, planted by default) — a human drives the
28
+ loop and ends it. One conversation accretes across turns.
29
+ - **Daemon** (`lyman add daemon_harness`) — launch once, loop indefinitely
30
+ on an inbound event stream; no human in the loop. Fresh conversation per
31
+ event.
32
+ - **Script** (`lyman add script_harness`) — launched by cron or on demand
33
+ with its work item in hand; processes it and halts. No loop in the shell
34
+ at all.
35
+
36
+ To build a new harness, start from the archetype whose shell shape matches
37
+ — the circuit rarely needs to change; the supplier of work items does.
38
+
21
39
  ## The managed/owned boundary
22
40
 
23
41
  Everything under the `Lyman::` namespace (`lib/lyman.rb`, `lib/lyman/`) is
@@ -28,27 +46,34 @@ which takes ownership explicitly rather than leaving it silently forked.
28
46
  `.lyman/manifest.yml` is the record of what's managed, what's owned, and what
29
47
  you've ejected; commit it.
30
48
 
31
- `harness/chat.rb` and everything outside `Lyman::` is **owned** — yours from
32
- day one, never touched by `lyman update`. Put your own workers in your own
33
- namespace or directory, not inside `lib/lyman/`.
49
+ The harness scripts (`harness/*.rb`) and everything outside `Lyman::` are
50
+ **owned** — yours from day one, never touched by `lyman update`. Put your own
51
+ workers in your own namespace or directory, not inside `lib/lyman/`.
52
+
53
+ ## Five load-bearing facts
34
54
 
35
- ## Four load-bearing facts
55
+ 1. **Frozen handoffs.** Shifty (0.6+) deep-freezes every value at a worker
56
+ boundary; a task that mutates its input raises `Shifty::PolicyViolation`.
57
+ `Conversation` is an immutable value: express change with its `with_*`
58
+ methods (a new conversation comes back) and rebind shell state to what
59
+ the pipeline returns — never mutate in place. Closure state inside a
60
+ worker stays freely mutable; only handed-off values freeze.
36
61
 
37
- 1. **The nil-source footgun.** In shifty, a source returning `nil` ends the
62
+ 2. **The nil-source footgun.** In shifty, a source returning `nil` ends the
38
63
  stream permanently. If you're driving a pipeline off a queue, enqueue
39
64
  before you shift — never pull a source worker while its queue is empty.
40
65
 
41
- 2. **The runaway-turn guard.** The model⇄tool circuit is bounded by
66
+ 3. **The runaway-turn guard.** The model⇄tool circuit is bounded by
42
67
  `Conversation#runaway?` / `max_rounds`. Keep that guard intact when
43
68
  rewiring the circuit; without it, a model that keeps calling tools never
44
69
  lets a turn end.
45
70
 
46
- 3. **Item-as-control discipline.** An item may tell a worker *whether* to
71
+ 4. **Item-as-control discipline.** An item may tell a worker *whether* to
47
72
  act, never *which of several things* to do. A worker that switches
48
73
  between jobs based on item state is the anti-pattern to avoid
49
74
  (multi-way dispatch) — split it into stages, or use a splitter, instead.
50
75
 
51
- 4. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
76
+ 5. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
52
77
  the `Conversation` for observability, but `Workers.wire_messages` strips
53
78
  it before anything goes out over the wire. Preserve that separation if
54
79
  you touch message handling.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lyman
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Helbling
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: shifty
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: '0.6'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: '0.6'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ostruct
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -58,13 +58,23 @@ executables:
58
58
  extensions: []
59
59
  extra_rdoc_files: []
60
60
  files:
61
+ - CHANGELOG.md
61
62
  - LICENSE
62
63
  - README.md
63
64
  - docs/design/circuit-pattern.md
64
65
  - docs/design/deployment.md
66
+ - docs/design/harness-archetypes.md
67
+ - docs/design/immutable-conversation.md
65
68
  - docs/vision.md
66
69
  - exe/lyman
67
- - harness/chat.rb
70
+ - harness/daemon.rb
71
+ - harness/repl.rb
72
+ - harness/repl/round_printer.rb
73
+ - harness/repl/style.rb
74
+ - harness/repl/think_filter.rb
75
+ - harness/repl/tool_printer.rb
76
+ - harness/repl/wait_spinner.rb
77
+ - harness/script.rb
68
78
  - lib/lyman.rb
69
79
  - lib/lyman/cli.rb
70
80
  - lib/lyman/cli/commands/add.rb
@@ -91,6 +101,7 @@ licenses:
91
101
  - MIT
92
102
  metadata:
93
103
  homepage_uri: https://github.com/joelhelbling/lyman
104
+ changelog_uri: https://github.com/joelhelbling/lyman/blob/main/CHANGELOG.md
94
105
  rdoc_options: []
95
106
  require_paths:
96
107
  - lib
data/harness/chat.rb DELETED
@@ -1,244 +0,0 @@
1
- #!/usr/bin/env ruby
2
- #
3
- # The shell: state + a process. Deliberately boring.
4
- #
5
- # This file is the one legible wiring script — the circuit pattern from
6
- # docs/design/circuit-pattern.md, wired filter-in: one `pipeline.shift`
7
- # per turn, with all model⇄tool rounds happening inside the call.
8
- #
9
- # Everything the model does streams to the terminal as it happens —
10
- # pre-tool narration, tool calls, the final answer. Fitting the display
11
- # to the model (like hiding a <think> block) is this shell's job, not
12
- # the library's.
13
-
14
- $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
15
- require "lyman"
16
-
17
- # This is a top-level wiring script by design; mixing the DSL into main
18
- # is the point, not an accident.
19
- include Shifty::DSL # standard:disable Style/MixinUsage
20
-
21
- $stdout.sync = true
22
-
23
- BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
24
- MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
25
-
26
- # ── Tools: schema and handler side by side, guts on the outside ────────────
27
- TOOLS = {
28
- "current_time" => {
29
- schema: {
30
- "type" => "function",
31
- "function" => {
32
- "name" => "current_time",
33
- "description" => "Returns the current local date and time",
34
- "parameters" => {"type" => "object", "properties" => {}, "required" => []}
35
- }
36
- },
37
- handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
38
- }
39
- }
40
-
41
- schemas = TOOLS.values.map { |tool| tool[:schema] }
42
- handlers = TOOLS.transform_values { |tool| tool[:handler] }
43
-
44
- # ── Display: fitting this harness to its models ─────────────────────────────
45
-
46
- # Thinking models prefix replies with <think>...</think> (the worker
47
- # normalizes separate-reasoning-field servers to the same convention). When
48
- # the reply arrives one fragment at a time we can't regex the whole thing, so
49
- # this streams a short preview of the thinking — the first few lines — then
50
- # elides the rest, closing with "...</think>" once the model stops thinking.
51
- class ThinkFilter
52
- OPEN = "<think>"
53
- CLOSE = "</think>"
54
- SNIPPET_LINES = 3
55
- SNIPPET_CHARS = 240 # think blocks are often one long unwrapped paragraph
56
-
57
- def initialize
58
- @state = :start
59
- @buffer = +""
60
- @lines = 0
61
- @chars = 0
62
- @truncated = false
63
- end
64
-
65
- # Returns the printable portion of this fragment.
66
- def filter(fragment)
67
- @buffer << fragment
68
- case @state
69
- when :start then filter_start
70
- when :thinking then filter_thinking
71
- when :after_think then filter_after_think
72
- when :passing then take_buffer
73
- end
74
- end
75
-
76
- # Call when the message is complete: releases anything still held back
77
- # (e.g. a reply that was nothing but "<thin"), and closes a think block
78
- # the model never closed itself.
79
- def flush
80
- case @state
81
- when :start then take_buffer
82
- when :thinking then (@truncated ? "..." : "") + CLOSE
83
- else ""
84
- end
85
- end
86
-
87
- private
88
-
89
- def take_buffer
90
- out = @buffer
91
- @buffer = +""
92
- out
93
- end
94
-
95
- def filter_start
96
- if @buffer.start_with?(OPEN)
97
- @state = :thinking
98
- @buffer = @buffer[OPEN.length..]
99
- OPEN + filter_thinking
100
- elsif OPEN.start_with?(@buffer)
101
- "" # could still become <think>; wait for more
102
- else
103
- @state = :passing
104
- take_buffer
105
- end
106
- end
107
-
108
- def filter_thinking
109
- if (idx = @buffer.index(CLOSE))
110
- thought = @buffer[0...idx]
111
- @buffer = @buffer[(idx + CLOSE.length)..]
112
- @state = :after_think
113
- snippet(thought) + (@truncated ? "..." : "") + CLOSE + "\n\n" + filter_after_think
114
- else
115
- # Keep any tail that could be the start of CLOSE split across
116
- # fragments; preview the rest.
117
- keep = partial_close_suffix
118
- thought = @buffer[0, @buffer.length - keep.length]
119
- @buffer = keep
120
- snippet(thought)
121
- end
122
- end
123
-
124
- # The leading portion of the thought that fits the preview budget.
125
- def snippet(text)
126
- return "" if @truncated
127
- out = +""
128
- text.each_char do |ch|
129
- if @chars >= SNIPPET_CHARS || (ch == "\n" && @lines >= SNIPPET_LINES - 1)
130
- @truncated = true
131
- break
132
- end
133
- out << ch
134
- @chars += 1
135
- @lines += 1 if ch == "\n"
136
- end
137
- out
138
- end
139
-
140
- # We print "</think>\n\n" ourselves, so swallow the model's own
141
- # whitespace between the close tag and the reply.
142
- def filter_after_think
143
- stripped = @buffer.lstrip
144
- if stripped.empty?
145
- @buffer = +""
146
- ""
147
- else
148
- @state = :passing
149
- @buffer = stripped
150
- take_buffer
151
- end
152
- end
153
-
154
- def partial_close_suffix
155
- (1...CLOSE.length).reverse_each do |len|
156
- tail = @buffer[-len, len]
157
- return tail if tail && CLOSE.start_with?(tail)
158
- end
159
- +""
160
- end
161
- end
162
-
163
- # Streams one round's content to the terminal, printing the model label
164
- # before the first visible text so silent rounds (pure tool calls) don't
165
- # leave an empty prompt behind.
166
- class RoundPrinter
167
- def initialize(label)
168
- @label = label
169
- end
170
-
171
- def start_round
172
- @filter = ThinkFilter.new
173
- @printed = false
174
- end
175
-
176
- def delta(text)
177
- emit(@filter.filter(text))
178
- end
179
-
180
- def finish_round
181
- emit(@filter.flush)
182
- puts if @printed
183
- end
184
-
185
- private
186
-
187
- def emit(text)
188
- return if text.empty?
189
- print "\n#{@label}> " unless @printed
190
- @printed = true
191
- print text
192
- end
193
- end
194
-
195
- # ── Shell state ─────────────────────────────────────────────────────────────
196
- conversation = Lyman::Conversation.new(
197
- system_prompt: "You are a helpful assistant. Keep replies brief."
198
- )
199
- rounds = [] # the circuit's queue — visible right here, not smuggled
200
- printer = RoundPrinter.new(MODEL)
201
-
202
- # ── The circuit ─────────────────────────────────────────────────────────────
203
- pipeline =
204
- source_worker { rounds.shift } |
205
- side_worker { |_c| printer.start_round } |
206
- Lyman::Workers.chat_completion(
207
- base_url: BASE_URL, model: MODEL, tools: schemas,
208
- on_delta: printer.method(:delta)
209
- ) |
210
- side_worker { |_c| printer.finish_round } |
211
- relay_worker { |c|
212
- c.finish! if c.pending_tool_calls.empty? || c.runaway?
213
- c
214
- } |
215
- side_worker do |c|
216
- unless c.finished?
217
- c.pending_tool_calls.each do |tc|
218
- puts " ⚙ #{tc.dig("function", "name")} #{tc.dig("function", "arguments")}"
219
- end
220
- end
221
- end |
222
- Lyman::Workers.tool_execution(handlers) |
223
- side_worker { |c| rounds << c unless c.finished? } |
224
- filter_worker { |c| c.finished? }
225
-
226
- # ── Shell process ───────────────────────────────────────────────────────────
227
- puts <<~PREAMBLE
228
- lyman ⇢ #{MODEL} @ #{BASE_URL}
229
-
230
- exit: blank line or ctrl-d
231
- model: LYMAN_MODEL=qwen3.5:2b #{$PROGRAM_NAME}
232
- endpoint: LYMAN_BASE_URL=http://localhost:1234/v1 #{$PROGRAM_NAME}
233
- tools: #{TOOLS.keys.join(", ")} — a ⚙ line appears when the model calls one
234
-
235
- PREAMBLE
236
-
237
- loop do
238
- print "\nyou> "
239
- input = $stdin.gets&.strip
240
- break if input.nil? || input.empty?
241
-
242
- rounds << conversation.add_user_message(input)
243
- pipeline.shift
244
- end