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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +109 -0
- data/README.md +47 -13
- data/docs/design/harness-archetypes.md +153 -0
- data/docs/design/immutable-conversation.md +71 -0
- data/harness/daemon.rb +119 -0
- data/harness/repl/round_printer.rb +43 -0
- data/harness/repl/style.rb +13 -0
- data/harness/repl/think_filter.rb +127 -0
- data/harness/repl/tool_printer.rb +36 -0
- data/harness/repl/wait_spinner.rb +28 -0
- data/harness/repl.rb +116 -0
- data/harness/script.rb +81 -0
- data/lib/lyman/cli/commands/new.rb +4 -1
- data/lib/lyman/cli/doctor_check.rb +7 -9
- data/lib/lyman/cli/registry.rb +58 -7
- data/lib/lyman/cli/version.rb +1 -1
- data/lib/lyman/conversation.rb +30 -29
- data/lib/lyman/workers/chat_completion.rb +3 -5
- data/lib/lyman/workers/tool_execution.rb +22 -19
- data/templates/CLAUDE.md +35 -10
- data/templates/Gemfile +2 -2
- data/templates/SKILL.md +36 -11
- metadata +18 -7
- data/harness/chat.rb +0 -348
|
@@ -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
|
-
#
|
|
10
|
-
# and the model has asked for
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
|
28
|
-
day one, never touched by `lyman update`. Put 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
32
|
-
day one, never touched by `lyman update`. Put 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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.
|
|
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
|