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.
- 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 +59 -8
- 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 +38 -12
- data/templates/Gemfile +4 -1
- data/templates/SKILL.md +36 -11
- metadata +17 -6
- data/harness/chat.rb +0 -244
|
@@ -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,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/
|
|
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 (
|
|
11
|
-
project has no runtime dependency on
|
|
12
|
-
|
|
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
|
|
27
|
-
day one, never touched by `lyman update`. Put 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
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
|