lyman 0.2.1 → 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,7 +2,7 @@
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
@@ -10,10 +10,28 @@ against any OpenAI-compatible chat completions endpoint (Ollama by default).
10
10
  - `bundle install` — install dependencies (`shifty`, `ostruct`, and `cli-ui`
11
11
  for the harness's display layer; this project has no runtime dependency on
12
12
  the `lyman` gem itself).
13
- - `ruby harness/chat.rb` — run the interactive chat harness. Defaults to
13
+ - `ruby harness/repl.rb` — run the interactive repl harness. Defaults to
14
14
  Ollama at `http://localhost:11434/v1`; override with `LYMAN_MODEL` and
15
15
  `LYMAN_BASE_URL` env vars.
16
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
+
17
35
  ## The managed/owned boundary
18
36
 
19
37
  Everything under the `Lyman::` namespace (`lib/lyman.rb`, `lib/lyman/`) is
@@ -24,27 +42,34 @@ which takes ownership explicitly rather than leaving it silently forked.
24
42
  `.lyman/manifest.yml` is the record of what's managed, what's owned, and what
25
43
  you've ejected; commit it.
26
44
 
27
- `harness/chat.rb` and everything outside `Lyman::` is **owned** — yours from
28
- day one, never touched by `lyman update`. Put your own workers in your own
29
- 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
30
50
 
31
- ## 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.
32
57
 
33
- 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
34
59
  stream permanently. If you're driving a pipeline off a queue, enqueue
35
60
  before you shift — never pull a source worker while its queue is empty.
36
61
 
37
- 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
38
63
  `Conversation#runaway?` / `max_rounds`. Keep that guard intact when
39
64
  rewiring the circuit; without it, a model that keeps calling tools never
40
65
  lets a turn end.
41
66
 
42
- 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
43
68
  act, never *which of several things* to do. A worker that switches
44
69
  between jobs based on item state is the anti-pattern to avoid
45
70
  (multi-way dispatch) — split it into stages, or use a splitter, instead.
46
71
 
47
- 4. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
72
+ 5. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
48
73
  the `Conversation` for observability, but `Workers.wire_messages` strips
49
74
  it before anything goes out over the wire. Preserve that separation if
50
75
  you touch message handling.
data/templates/Gemfile CHANGED
@@ -1,7 +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/chat.rb's display layer; drop them if you restyle.
5
+ # Used only by harness/repl.rb's display layer; drop them if you restyle.
6
6
  gem "cli-ui"
7
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.1
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
@@ -105,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
116
  - !ruby/object:Gem::Version
106
117
  version: '0'
107
118
  requirements: []
108
- rubygems_version: 4.0.15
119
+ rubygems_version: 4.0.10
109
120
  specification_version: 4
110
121
  summary: A composable agentic harness, delivered as code you own — a pure generator
111
122
  in the shadcn/ui mold, built on shifty
data/harness/chat.rb DELETED
@@ -1,348 +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
- # Everything from here to "Shell state" is presentation. Its dependencies —
46
- # cli-ui for color and glyphs, reline (stdlib) for prompt line-editing and
47
- # history — are confined to this section; restyle or delete freely without
48
- # touching the circuit.
49
- require "cli/ui"
50
- require "reline"
51
-
52
- # Styling codes used across streamed fragments, where CLI::UI.fmt can't help
53
- # (fmt resets at the end of each call; a think preview arrives in pieces).
54
- # Empty when piped, matching cli-ui's own no-tty behavior.
55
- TTY = $stdout.tty?
56
- GRAY = TTY ? CLI::UI.resolve_color(:gray).code : ""
57
- DIM = TTY ? "\e[2;3m" : "" # faint italic, for think previews
58
- RESET = TTY ? CLI::UI::Color::RESET.code : ""
59
-
60
- # Paint without CLI::UI.fmt so text we don't control (tool arguments and
61
- # results) can't be misread as {{markup}}.
62
- def gray(text) = "#{GRAY}#{text}#{RESET}"
63
-
64
- # Thinking models prefix replies with <think>...</think> (the worker
65
- # normalizes separate-reasoning-field servers to the same convention). When
66
- # the reply arrives one fragment at a time we can't regex the whole thing, so
67
- # this streams a short preview of the thinking — the first few lines,
68
- # rendered as a dim "✻ …" aside rather than the literal tags — then elides
69
- # the rest once the model stops thinking.
70
- #
71
- # Emits [think_text, reply_text] pairs. Both stream straight to the terminal
72
- # today, but the split is the seam for reply-side processing (a markdown
73
- # renderer, say — mind that anything buffering the reply trades away its
74
- # token-by-token streaming, which is why this harness doesn't ship one).
75
- class ThinkFilter
76
- OPEN = "<think>"
77
- CLOSE = "</think>"
78
- MARK = "✻ "
79
- SNIPPET_LINES = 3
80
- SNIPPET_CHARS = 240 # think blocks are often one long unwrapped paragraph
81
-
82
- def initialize
83
- @state = :start
84
- @buffer = +""
85
- @lines = 0
86
- @chars = 0
87
- @truncated = false
88
- end
89
-
90
- # Returns the printable portion of this fragment as a
91
- # [think_text, reply_text] pair (either may be empty).
92
- def filter(fragment)
93
- @buffer << fragment
94
- case @state
95
- when :start then filter_start
96
- when :thinking then filter_thinking
97
- when :after_think then ["", filter_after_think]
98
- when :passing then ["", take_buffer]
99
- end
100
- end
101
-
102
- # Call when the message is complete: releases anything still held back
103
- # (e.g. a reply that was nothing but "<thin"), and closes out the styling
104
- # of a think block the model never closed itself.
105
- def flush
106
- case @state
107
- when :start then ["", take_buffer]
108
- when :thinking then [(@truncated ? "…" : "") + RESET, ""]
109
- else ["", ""]
110
- end
111
- end
112
-
113
- private
114
-
115
- def take_buffer
116
- out = @buffer
117
- @buffer = +""
118
- out
119
- end
120
-
121
- def filter_start
122
- if @buffer.start_with?(OPEN)
123
- @state = :thinking
124
- @buffer = @buffer[OPEN.length..]
125
- think, reply = filter_thinking
126
- [DIM + MARK + think, reply]
127
- elsif OPEN.start_with?(@buffer)
128
- ["", ""] # could still become <think>; wait for more
129
- else
130
- @state = :passing
131
- ["", take_buffer]
132
- end
133
- end
134
-
135
- def filter_thinking
136
- if (idx = @buffer.index(CLOSE))
137
- thought = @buffer[0...idx]
138
- @buffer = @buffer[(idx + CLOSE.length)..]
139
- @state = :after_think
140
- [snippet(thought) + (@truncated ? "…" : "") + RESET + "\n\n", filter_after_think]
141
- else
142
- # Keep any tail that could be the start of CLOSE split across
143
- # fragments; preview the rest.
144
- keep = partial_close_suffix
145
- thought = @buffer[0, @buffer.length - keep.length]
146
- @buffer = keep
147
- [snippet(thought), ""]
148
- end
149
- end
150
-
151
- # The leading portion of the thought that fits the preview budget.
152
- def snippet(text)
153
- return "" if @truncated
154
- out = +""
155
- text.each_char do |ch|
156
- if @chars >= SNIPPET_CHARS || (ch == "\n" && @lines >= SNIPPET_LINES - 1)
157
- @truncated = true
158
- break
159
- end
160
- out << ch
161
- @chars += 1
162
- @lines += 1 if ch == "\n"
163
- end
164
- out
165
- end
166
-
167
- # We end the think preview with "\n\n" ourselves, so swallow the model's
168
- # own whitespace between the close tag and the reply.
169
- def filter_after_think
170
- stripped = @buffer.lstrip
171
- if stripped.empty?
172
- @buffer = +""
173
- ""
174
- else
175
- @state = :passing
176
- @buffer = stripped
177
- take_buffer
178
- end
179
- end
180
-
181
- def partial_close_suffix
182
- (1...CLOSE.length).reverse_each do |len|
183
- tail = @buffer[-len, len]
184
- return tail if tail && CLOSE.start_with?(tail)
185
- end
186
- +""
187
- end
188
- end
189
-
190
- # The silence between sending a prompt and the first streamed token can be
191
- # long on a local model (prefill). cli-ui's spinner owns the calling thread
192
- # for the duration of a block, which can't express "spin until the first
193
- # delta arrives" — so this is the one hand-rolled widget: a background
194
- # spinner with a stop method. Quiet when output is piped.
195
- class WaitSpinner
196
- FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏]
197
-
198
- def start(label)
199
- return unless TTY
200
- stop
201
- @thread = Thread.new do
202
- FRAMES.cycle do |frame|
203
- print "\r#{GRAY}#{frame} #{label}#{RESET}"
204
- sleep 0.08
205
- end
206
- end
207
- end
208
-
209
- def stop
210
- return unless @thread
211
- @thread.kill.join
212
- @thread = nil
213
- print "\r\e[K" # erase the spinner line
214
- end
215
- end
216
-
217
- # Streams one round's content to the terminal: a spinner while waiting on
218
- # the model, then the model label before the first visible text — so silent
219
- # rounds (pure tool calls) don't leave an empty prompt behind.
220
- class RoundPrinter
221
- def initialize(label)
222
- @label = label
223
- @spinner = WaitSpinner.new
224
- end
225
-
226
- def start_round
227
- @think = ThinkFilter.new
228
- @printed = false
229
- @spinner.start("waiting for #{@label}…")
230
- end
231
-
232
- def delta(text)
233
- think, reply = @think.filter(text)
234
- emit(think)
235
- emit(reply)
236
- end
237
-
238
- def finish_round
239
- @spinner.stop
240
- think, reply = @think.flush
241
- emit(think)
242
- emit(reply)
243
- puts if @printed
244
- end
245
-
246
- private
247
-
248
- def emit(text)
249
- return if text.empty?
250
- @spinner.stop
251
- print CLI::UI.fmt("\n{{magenta:#{@label}}}> ") unless @printed
252
- @printed = true
253
- print text
254
- end
255
- end
256
-
257
- # Prints tool activity around the execution stage: the calls the model
258
- # requested on the way in, a ✓-and-result line on the way out. Results are
259
- # summarized to one line here — the full text is on the conversation, where
260
- # the model (and any logging side-worker) sees it.
261
- class ToolPrinter
262
- RESULT_WIDTH = 60
263
-
264
- def calls(conversation)
265
- conversation.pending_tool_calls.each do |tool_call|
266
- puts gray(" ⚙ #{tool_call.dig("function", "name")} #{tool_call.dig("function", "arguments")}")
267
- end
268
- end
269
-
270
- def results(conversation)
271
- messages = conversation.messages
272
- request = messages.rindex { |m| m["role"] == "assistant" }
273
- return unless request
274
- names = (messages[request]["tool_calls"] || [])
275
- .to_h { |tc| [tc["id"], tc.dig("function", "name")] }
276
- messages[(request + 1)..].each do |message|
277
- next unless message["role"] == "tool"
278
- summary = gray("#{names[message["tool_call_id"]]} → #{summarize(message["content"])}")
279
- puts " #{CLI::UI.fmt("{{v}}")} #{summary}"
280
- end
281
- end
282
-
283
- private
284
-
285
- def summarize(text)
286
- line = text.to_s.gsub(/\s+/, " ").strip
287
- (line.length > RESULT_WIDTH) ? "#{line[0, RESULT_WIDTH - 1]}…" : line
288
- end
289
- end
290
-
291
- # ── Shell state ─────────────────────────────────────────────────────────────
292
- conversation = Lyman::Conversation.new(
293
- system_prompt: "You are a helpful assistant. Keep replies brief. " \
294
- "Your replies are printed verbatim in a plain-text terminal: write prose, " \
295
- "and avoid markdown and LaTeX markup."
296
- )
297
- rounds = [] # the circuit's queue — visible right here, not smuggled
298
- printer = RoundPrinter.new(MODEL)
299
- tool_printer = ToolPrinter.new
300
-
301
- # ── The circuit ─────────────────────────────────────────────────────────────
302
- pipeline =
303
- source_worker { rounds.shift } |
304
- side_worker { |_c| printer.start_round } |
305
- Lyman::Workers.chat_completion(
306
- base_url: BASE_URL, model: MODEL, tools: schemas,
307
- on_delta: printer.method(:delta)
308
- ) |
309
- side_worker { |_c| printer.finish_round } |
310
- relay_worker { |c|
311
- c.finish! if c.pending_tool_calls.empty? || c.runaway?
312
- c
313
- } |
314
- side_worker { |c| tool_printer.calls(c) unless c.finished? } |
315
- Lyman::Workers.tool_execution(handlers) |
316
- side_worker { |c| tool_printer.results(c) unless c.finished? } |
317
- side_worker { |c| rounds << c unless c.finished? } |
318
- filter_worker { |c| c.finished? }
319
-
320
- # ── Shell process ───────────────────────────────────────────────────────────
321
- puts CLI::UI.fmt("{{bold:lyman}} ⇢ {{magenta:#{MODEL}}} @ {{blue:#{BASE_URL}}}")
322
- puts
323
- puts gray(<<~HINTS.gsub(/^/, " "))
324
- exit: blank line, ctrl-c, or ctrl-d
325
- change the model: LYMAN_MODEL=qwen3.5:2b #{$PROGRAM_NAME}
326
- change the endpoint: LYMAN_BASE_URL=http://localhost:1234/v1 #{$PROGRAM_NAME}
327
- available tools: #{TOOLS.keys.join(", ")} — a ⚙ line appears when the model calls one
328
- HINTS
329
-
330
- loop do
331
- puts
332
- # Reline gives the prompt line editing and history, but emits screen-
333
- # redraw escapes even when input is piped — so scripted runs get gets.
334
- input = begin
335
- if $stdin.tty?
336
- Reline.readline(CLI::UI.fmt("{{cyan:you}}> "), true)&.strip
337
- else
338
- print "you> "
339
- $stdin.gets&.strip
340
- end
341
- rescue Interrupt
342
- nil # ctrl-c leaves like ctrl-d, not with a stack trace
343
- end
344
- break if input.nil? || input.empty?
345
-
346
- rounds << conversation.add_user_message(input)
347
- pipeline.shift
348
- end