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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require_relative "style"
|
|
2
|
+
|
|
3
|
+
# Thinking models prefix replies with <think>...</think> (the worker
|
|
4
|
+
# normalizes separate-reasoning-field servers to the same convention). When
|
|
5
|
+
# the reply arrives one fragment at a time we can't regex the whole thing, so
|
|
6
|
+
# this streams a short preview of the thinking — the first few lines,
|
|
7
|
+
# rendered as a dim "✻ …" aside rather than the literal tags — then elides
|
|
8
|
+
# the rest once the model stops thinking.
|
|
9
|
+
#
|
|
10
|
+
# Emits [think_text, reply_text] pairs. Both stream straight to the terminal
|
|
11
|
+
# today, but the split is the seam for reply-side processing (a markdown
|
|
12
|
+
# renderer, say — mind that anything buffering the reply trades away its
|
|
13
|
+
# token-by-token streaming, which is why this harness doesn't ship one).
|
|
14
|
+
class ThinkFilter
|
|
15
|
+
OPEN = "<think>"
|
|
16
|
+
CLOSE = "</think>"
|
|
17
|
+
MARK = "✻ "
|
|
18
|
+
SNIPPET_LINES = 3
|
|
19
|
+
SNIPPET_CHARS = 240 # think blocks are often one long unwrapped paragraph
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@state = :start
|
|
23
|
+
@buffer = +""
|
|
24
|
+
@lines = 0
|
|
25
|
+
@chars = 0
|
|
26
|
+
@truncated = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the printable portion of this fragment as a
|
|
30
|
+
# [think_text, reply_text] pair (either may be empty).
|
|
31
|
+
def filter(fragment)
|
|
32
|
+
@buffer << fragment
|
|
33
|
+
case @state
|
|
34
|
+
when :start then filter_start
|
|
35
|
+
when :thinking then filter_thinking
|
|
36
|
+
when :after_think then ["", filter_after_think]
|
|
37
|
+
when :passing then ["", take_buffer]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Call when the message is complete: releases anything still held back
|
|
42
|
+
# (e.g. a reply that was nothing but "<thin"), and closes out the styling
|
|
43
|
+
# of a think block the model never closed itself.
|
|
44
|
+
def flush
|
|
45
|
+
case @state
|
|
46
|
+
when :start then ["", take_buffer]
|
|
47
|
+
when :thinking then [(@truncated ? "…" : "") + RESET, ""]
|
|
48
|
+
else ["", ""]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def take_buffer
|
|
55
|
+
out = @buffer
|
|
56
|
+
@buffer = +""
|
|
57
|
+
out
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def filter_start
|
|
61
|
+
if @buffer.start_with?(OPEN)
|
|
62
|
+
@state = :thinking
|
|
63
|
+
@buffer = @buffer[OPEN.length..]
|
|
64
|
+
think, reply = filter_thinking
|
|
65
|
+
[DIM + MARK + think, reply]
|
|
66
|
+
elsif OPEN.start_with?(@buffer)
|
|
67
|
+
["", ""] # could still become <think>; wait for more
|
|
68
|
+
else
|
|
69
|
+
@state = :passing
|
|
70
|
+
["", take_buffer]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def filter_thinking
|
|
75
|
+
if (idx = @buffer.index(CLOSE))
|
|
76
|
+
thought = @buffer[0...idx]
|
|
77
|
+
@buffer = @buffer[(idx + CLOSE.length)..]
|
|
78
|
+
@state = :after_think
|
|
79
|
+
[snippet(thought) + (@truncated ? "…" : "") + RESET + "\n\n", filter_after_think]
|
|
80
|
+
else
|
|
81
|
+
# Keep any tail that could be the start of CLOSE split across
|
|
82
|
+
# fragments; preview the rest.
|
|
83
|
+
keep = partial_close_suffix
|
|
84
|
+
thought = @buffer[0, @buffer.length - keep.length]
|
|
85
|
+
@buffer = keep
|
|
86
|
+
[snippet(thought), ""]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The leading portion of the thought that fits the preview budget.
|
|
91
|
+
def snippet(text)
|
|
92
|
+
return "" if @truncated
|
|
93
|
+
out = +""
|
|
94
|
+
text.each_char do |ch|
|
|
95
|
+
if @chars >= SNIPPET_CHARS || (ch == "\n" && @lines >= SNIPPET_LINES - 1)
|
|
96
|
+
@truncated = true
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
out << ch
|
|
100
|
+
@chars += 1
|
|
101
|
+
@lines += 1 if ch == "\n"
|
|
102
|
+
end
|
|
103
|
+
out
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# We end the think preview with "\n\n" ourselves, so swallow the model's
|
|
107
|
+
# own whitespace between the close tag and the reply.
|
|
108
|
+
def filter_after_think
|
|
109
|
+
stripped = @buffer.lstrip
|
|
110
|
+
if stripped.empty?
|
|
111
|
+
@buffer = +""
|
|
112
|
+
""
|
|
113
|
+
else
|
|
114
|
+
@state = :passing
|
|
115
|
+
@buffer = stripped
|
|
116
|
+
take_buffer
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def partial_close_suffix
|
|
121
|
+
(1...CLOSE.length).reverse_each do |len|
|
|
122
|
+
tail = @buffer[-len, len]
|
|
123
|
+
return tail if tail && CLOSE.start_with?(tail)
|
|
124
|
+
end
|
|
125
|
+
+""
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "cli/ui"
|
|
2
|
+
require_relative "style"
|
|
3
|
+
|
|
4
|
+
# Prints tool activity around the execution stage: the calls the model
|
|
5
|
+
# requested on the way in, a ✓-and-result line on the way out. Results are
|
|
6
|
+
# summarized to one line here — the full text is on the conversation, where
|
|
7
|
+
# the model (and any logging side-worker) sees it.
|
|
8
|
+
class ToolPrinter
|
|
9
|
+
RESULT_WIDTH = 60
|
|
10
|
+
|
|
11
|
+
def calls(conversation)
|
|
12
|
+
conversation.pending_tool_calls.each do |tool_call|
|
|
13
|
+
puts gray(" ⚙ #{tool_call.dig("function", "name")} #{tool_call.dig("function", "arguments")}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def results(conversation)
|
|
18
|
+
messages = conversation.messages
|
|
19
|
+
request = messages.rindex { |m| m["role"] == "assistant" }
|
|
20
|
+
return unless request
|
|
21
|
+
names = (messages[request]["tool_calls"] || [])
|
|
22
|
+
.to_h { |tc| [tc["id"], tc.dig("function", "name")] }
|
|
23
|
+
messages[(request + 1)..].each do |message|
|
|
24
|
+
next unless message["role"] == "tool"
|
|
25
|
+
summary = gray("#{names[message["tool_call_id"]]} → #{summarize(message["content"])}")
|
|
26
|
+
puts " #{CLI::UI.fmt("{{v}}")} #{summary}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def summarize(text)
|
|
33
|
+
line = text.to_s.gsub(/\s+/, " ").strip
|
|
34
|
+
(line.length > RESULT_WIDTH) ? "#{line[0, RESULT_WIDTH - 1]}…" : line
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative "style"
|
|
2
|
+
|
|
3
|
+
# The silence between sending a prompt and the first streamed token can be
|
|
4
|
+
# long on a local model (prefill). cli-ui's spinner owns the calling thread
|
|
5
|
+
# for the duration of a block, which can't express "spin until the first
|
|
6
|
+
# delta arrives" — so this is the one hand-rolled widget: a background
|
|
7
|
+
# spinner with a stop method. Quiet when output is piped.
|
|
8
|
+
class WaitSpinner
|
|
9
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏]
|
|
10
|
+
|
|
11
|
+
def start(label)
|
|
12
|
+
return unless TTY
|
|
13
|
+
stop
|
|
14
|
+
@thread = Thread.new do
|
|
15
|
+
FRAMES.cycle do |frame|
|
|
16
|
+
print "\r#{GRAY}#{frame} #{label}#{RESET}"
|
|
17
|
+
sleep 0.08
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def stop
|
|
23
|
+
return unless @thread
|
|
24
|
+
@thread.kill.join
|
|
25
|
+
@thread = nil
|
|
26
|
+
print "\r\e[K" # erase the spinner line
|
|
27
|
+
end
|
|
28
|
+
end
|
data/harness/repl.rb
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
#
|
|
3
|
+
# The REPL archetype: a human drives the loop and decides when it ends.
|
|
4
|
+
# One of three archetype shells — repl, daemon, script — around the same
|
|
5
|
+
# circuit; see docs/design/harness-archetypes.md.
|
|
6
|
+
#
|
|
7
|
+
# The shell: state + a process. Deliberately boring.
|
|
8
|
+
#
|
|
9
|
+
# This file is the one legible wiring script — the circuit pattern from
|
|
10
|
+
# docs/design/circuit-pattern.md, wired filter-in: one `pipeline.shift`
|
|
11
|
+
# per turn, with all model⇄tool rounds happening inside the call.
|
|
12
|
+
#
|
|
13
|
+
# Everything the model does streams to the terminal as it happens —
|
|
14
|
+
# pre-tool narration, tool calls, the final answer. Fitting the display
|
|
15
|
+
# to the model (like hiding a <think> block) is this shell's job, not
|
|
16
|
+
# the library's — the harness/repl/ files are that display layer. Their
|
|
17
|
+
# dependencies (cli-ui for color and glyphs, reline for prompt
|
|
18
|
+
# line-editing and history) stay out of the circuit; restyle or delete
|
|
19
|
+
# them freely.
|
|
20
|
+
|
|
21
|
+
# require_relative, not the load path: these are files planted beside this
|
|
22
|
+
# script, not the lyman gem.
|
|
23
|
+
require_relative "../lib/lyman"
|
|
24
|
+
require_relative "repl/style"
|
|
25
|
+
require_relative "repl/round_printer"
|
|
26
|
+
require_relative "repl/tool_printer"
|
|
27
|
+
require "cli/ui"
|
|
28
|
+
require "reline"
|
|
29
|
+
|
|
30
|
+
# This is a top-level wiring script by design; mixing the DSL into main
|
|
31
|
+
# is the point, not an accident.
|
|
32
|
+
include Shifty::DSL # standard:disable Style/MixinUsage
|
|
33
|
+
|
|
34
|
+
$stdout.sync = true
|
|
35
|
+
|
|
36
|
+
BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
|
|
37
|
+
MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
|
|
38
|
+
|
|
39
|
+
# ── Tools: schema and handler side by side, guts on the outside ────────────
|
|
40
|
+
TOOLS = {
|
|
41
|
+
"current_time" => {
|
|
42
|
+
schema: {
|
|
43
|
+
"type" => "function",
|
|
44
|
+
"function" => {
|
|
45
|
+
"name" => "current_time",
|
|
46
|
+
"description" => "Returns the current local date and time",
|
|
47
|
+
"parameters" => {"type" => "object", "properties" => {}, "required" => []}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
schemas = TOOLS.values.map { |tool| tool[:schema] }
|
|
55
|
+
handlers = TOOLS.transform_values { |tool| tool[:handler] }
|
|
56
|
+
|
|
57
|
+
# ── Shell state ─────────────────────────────────────────────────────────────
|
|
58
|
+
conversation = Lyman::Conversation.new(
|
|
59
|
+
system_prompt: "You are a helpful assistant. Keep replies brief. " \
|
|
60
|
+
"Your replies are printed verbatim in a plain-text terminal: write prose, " \
|
|
61
|
+
"and avoid markdown and LaTeX markup."
|
|
62
|
+
)
|
|
63
|
+
rounds = [] # the circuit's queue — visible right here, not smuggled
|
|
64
|
+
printer = RoundPrinter.new(MODEL)
|
|
65
|
+
tool_printer = ToolPrinter.new
|
|
66
|
+
|
|
67
|
+
# ── The circuit ─────────────────────────────────────────────────────────────
|
|
68
|
+
pipeline =
|
|
69
|
+
source_worker { rounds.shift } |
|
|
70
|
+
side_worker { |_c| printer.start_round } |
|
|
71
|
+
Lyman::Workers.chat_completion(
|
|
72
|
+
base_url: BASE_URL, model: MODEL, tools: schemas,
|
|
73
|
+
on_delta: printer.method(:delta)
|
|
74
|
+
) |
|
|
75
|
+
side_worker { |_c| printer.finish_round } |
|
|
76
|
+
relay_worker { |c|
|
|
77
|
+
(c.pending_tool_calls.empty? || c.runaway?) ? c.finish : c
|
|
78
|
+
} |
|
|
79
|
+
side_worker { |c| tool_printer.calls(c) unless c.finished? } |
|
|
80
|
+
Lyman::Workers.tool_execution(handlers) |
|
|
81
|
+
side_worker { |c| tool_printer.results(c) unless c.finished? } |
|
|
82
|
+
side_worker { |c| rounds << c unless c.finished? } |
|
|
83
|
+
filter_worker { |c| c.finished? }
|
|
84
|
+
|
|
85
|
+
# ── Shell process ───────────────────────────────────────────────────────────
|
|
86
|
+
puts CLI::UI.fmt("{{bold:lyman}} ⇢ {{magenta:#{MODEL}}} @ {{blue:#{BASE_URL}}}")
|
|
87
|
+
puts
|
|
88
|
+
puts gray(<<~HINTS.gsub(/^/, " "))
|
|
89
|
+
exit: blank line, ctrl-c, or ctrl-d
|
|
90
|
+
change the model: LYMAN_MODEL=qwen3.5:2b #{$PROGRAM_NAME}
|
|
91
|
+
change the endpoint: LYMAN_BASE_URL=http://localhost:1234/v1 #{$PROGRAM_NAME}
|
|
92
|
+
available tools: #{TOOLS.keys.join(", ")} — a ⚙ line appears when the model calls one
|
|
93
|
+
HINTS
|
|
94
|
+
|
|
95
|
+
loop do
|
|
96
|
+
puts
|
|
97
|
+
# Reline gives the prompt line editing and history, but emits screen-
|
|
98
|
+
# redraw escapes even when input is piped — so scripted runs get gets.
|
|
99
|
+
input = begin
|
|
100
|
+
if $stdin.tty?
|
|
101
|
+
Reline.readline(CLI::UI.fmt("{{cyan:you}}> "), true)&.strip
|
|
102
|
+
else
|
|
103
|
+
print "you> "
|
|
104
|
+
$stdin.gets&.strip
|
|
105
|
+
end
|
|
106
|
+
rescue Interrupt
|
|
107
|
+
nil # ctrl-c leaves like ctrl-d, not with a stack trace
|
|
108
|
+
end
|
|
109
|
+
break if input.nil? || input.empty?
|
|
110
|
+
|
|
111
|
+
# Conversation is an immutable value (shifty 0.6 freezes every handoff),
|
|
112
|
+
# so state flows in the open: enqueue a new conversation carrying the
|
|
113
|
+
# user's message, and rebind to the finished turn the circuit hands back.
|
|
114
|
+
rounds << conversation.with_user_message(input)
|
|
115
|
+
conversation = pipeline.shift
|
|
116
|
+
end
|
data/harness/script.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
#
|
|
3
|
+
# The script archetype: launched by something out of scope — cron, a
|
|
4
|
+
# Makefile, a hand on a keyboard — with its work item in hand. It
|
|
5
|
+
# processes that one item (however many model⇄tool rounds it takes) and
|
|
6
|
+
# halts. One of three archetype shells — repl, daemon, script — around
|
|
7
|
+
# the same circuit; see docs/design/harness-archetypes.md.
|
|
8
|
+
#
|
|
9
|
+
# The shell: state + a process. Here the process needs no loop at all —
|
|
10
|
+
# the circuit still runs as many rounds as the model asks for, because
|
|
11
|
+
# repetition lives in the pipeline (docs/design/circuit-pattern.md), not
|
|
12
|
+
# in the shell. One enqueue, one shift, done.
|
|
13
|
+
#
|
|
14
|
+
# No display layer: output is the final answer on stdout, which is what
|
|
15
|
+
# a cron job or a calling script wants to capture. Everything between —
|
|
16
|
+
# the tool calls, the intermediate rounds — stays on the conversation,
|
|
17
|
+
# where a logging side_worker can be spliced in if you want to see it.
|
|
18
|
+
|
|
19
|
+
# require_relative, not the load path: these are files planted beside this
|
|
20
|
+
# script, not the lyman gem.
|
|
21
|
+
require_relative "../lib/lyman"
|
|
22
|
+
|
|
23
|
+
# This is a top-level wiring script by design; mixing the DSL into main
|
|
24
|
+
# is the point, not an accident.
|
|
25
|
+
include Shifty::DSL # standard:disable Style/MixinUsage
|
|
26
|
+
|
|
27
|
+
BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
|
|
28
|
+
MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
|
|
29
|
+
|
|
30
|
+
# ── Tools: schema and handler side by side, guts on the outside ────────────
|
|
31
|
+
TOOLS = {
|
|
32
|
+
"current_time" => {
|
|
33
|
+
schema: {
|
|
34
|
+
"type" => "function",
|
|
35
|
+
"function" => {
|
|
36
|
+
"name" => "current_time",
|
|
37
|
+
"description" => "Returns the current local date and time",
|
|
38
|
+
"parameters" => {"type" => "object", "properties" => {}, "required" => []}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
schemas = TOOLS.values.map { |tool| tool[:schema] }
|
|
46
|
+
handlers = TOOLS.transform_values { |tool| tool[:handler] }
|
|
47
|
+
|
|
48
|
+
# ── The work item ───────────────────────────────────────────────────────────
|
|
49
|
+
# It arrives at launch: arguments if given, stdin otherwise — the two
|
|
50
|
+
# channels a cron line or a calling script naturally speaks.
|
|
51
|
+
task = ARGV.empty? ? $stdin.read.to_s.strip : ARGV.join(" ")
|
|
52
|
+
abort "usage: #{$PROGRAM_NAME} TASK... (or pipe the task on stdin)" if task.empty?
|
|
53
|
+
|
|
54
|
+
# ── Shell state ─────────────────────────────────────────────────────────────
|
|
55
|
+
conversation = Lyman::Conversation.new(
|
|
56
|
+
system_prompt: "You are a task runner. Complete the given task and reply " \
|
|
57
|
+
"with the result only — plain text, no markdown, no commentary."
|
|
58
|
+
)
|
|
59
|
+
rounds = [] # the circuit's queue — visible right here, not smuggled
|
|
60
|
+
|
|
61
|
+
# ── The circuit ─────────────────────────────────────────────────────────────
|
|
62
|
+
pipeline =
|
|
63
|
+
source_worker { rounds.shift } |
|
|
64
|
+
Lyman::Workers.chat_completion(base_url: BASE_URL, model: MODEL, tools: schemas) |
|
|
65
|
+
relay_worker { |c|
|
|
66
|
+
(c.pending_tool_calls.empty? || c.runaway?) ? c.finish : c
|
|
67
|
+
} |
|
|
68
|
+
Lyman::Workers.tool_execution(handlers) |
|
|
69
|
+
side_worker { |c| rounds << c unless c.finished? } |
|
|
70
|
+
filter_worker { |c| c.finished? }
|
|
71
|
+
|
|
72
|
+
# ── Shell process ───────────────────────────────────────────────────────────
|
|
73
|
+
# Enqueue before shifting — never pull the source while the queue is empty
|
|
74
|
+
# (the nil footgun: a nil from a source ends the stream permanently).
|
|
75
|
+
# Conversation is an immutable value (shifty 0.6 freezes every handoff):
|
|
76
|
+
# with_user_message returns a new conversation, and the finished turn
|
|
77
|
+
# comes back from the pipeline.
|
|
78
|
+
rounds << conversation.with_user_message(task)
|
|
79
|
+
result = pipeline.shift
|
|
80
|
+
|
|
81
|
+
puts result.last_assistant_content
|
|
@@ -62,8 +62,11 @@ module Lyman
|
|
|
62
62
|
Next steps:
|
|
63
63
|
cd #{name}
|
|
64
64
|
bundle install
|
|
65
|
-
ruby harness/
|
|
65
|
+
ruby harness/repl.rb # defaults to Ollama at http://localhost:11434/v1
|
|
66
66
|
lyman doctor # smoke-test the pipeline
|
|
67
|
+
|
|
68
|
+
The repl is one of three harness archetypes; the other two are a
|
|
69
|
+
`lyman add` away (daemon_harness, script_harness).
|
|
67
70
|
EPILOGUE
|
|
68
71
|
end
|
|
69
72
|
end
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# process's own, and it's the client's bundle — not the CLI's — that needs
|
|
7
7
|
# to resolve shifty).
|
|
8
8
|
#
|
|
9
|
-
# Wires the same circuit shape as harness/
|
|
9
|
+
# Wires the same circuit shape as harness/repl.rb (docs/design/
|
|
10
10
|
# circuit-pattern.md): a queue-backed source, the model⇄tool round, the
|
|
11
11
|
# finish/requeue plumbing. The only substitution is the transport: a stub
|
|
12
12
|
# stands in at the chat_completion seam. That's legitimate, not a mock
|
|
@@ -20,13 +20,14 @@ require "./lib/lyman"
|
|
|
20
20
|
include Shifty::DSL # standard:disable Style/MixinUsage
|
|
21
21
|
|
|
22
22
|
conversation = Lyman::Conversation.new(system_prompt: "doctor")
|
|
23
|
-
|
|
24
|
-
rounds = [] # the circuit's queue, shell scope — mirrors harness/
|
|
23
|
+
.with_user_message("ping the tool once, then answer")
|
|
24
|
+
rounds = [] # the circuit's queue, shell scope — mirrors harness/repl.rb
|
|
25
25
|
|
|
26
26
|
# Stands in for Lyman::Workers.chat_completion: round 1 calls the "ping"
|
|
27
27
|
# tool, round 2 answers plainly. A real transport wouldn't know the round
|
|
28
28
|
# count in advance, but the seam only cares that something plays by the
|
|
29
|
-
# wire's rules —
|
|
29
|
+
# wire's rules — hand off a new conversation with the assistant message
|
|
30
|
+
# appended (with_assistant_message also counts the round).
|
|
30
31
|
call_count = 0
|
|
31
32
|
stub_transport = relay_worker do |c|
|
|
32
33
|
call_count += 1
|
|
@@ -46,17 +47,14 @@ stub_transport = relay_worker do |c|
|
|
|
46
47
|
else
|
|
47
48
|
{"role" => "assistant", "content" => "done"}
|
|
48
49
|
end
|
|
49
|
-
c.
|
|
50
|
-
c.rounds += 1
|
|
51
|
-
c
|
|
50
|
+
c.with_assistant_message(message)
|
|
52
51
|
end
|
|
53
52
|
|
|
54
53
|
pipeline =
|
|
55
54
|
source_worker { rounds.shift } |
|
|
56
55
|
stub_transport |
|
|
57
56
|
relay_worker { |c|
|
|
58
|
-
c.
|
|
59
|
-
c
|
|
57
|
+
(c.pending_tool_calls.empty? || c.runaway?) ? c.finish : c
|
|
60
58
|
} |
|
|
61
59
|
Lyman::Workers.tool_execution({"ping" => ->(_) { "pong" }}) |
|
|
62
60
|
side_worker { |c| rounds << c unless c.finished? } |
|
data/lib/lyman/cli/registry.rb
CHANGED
|
@@ -32,11 +32,62 @@ module Lyman
|
|
|
32
32
|
role: :managed,
|
|
33
33
|
description: "Relay worker: executes pending tool calls"
|
|
34
34
|
},
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
# The three harness archetypes (docs/design/harness-archetypes.md):
|
|
36
|
+
# same circuit, different shells. `new` plants the repl — the
|
|
37
|
+
# archetype you can talk to on day one; the other two are opt-in
|
|
38
|
+
# (`lyman add daemon_harness` / `lyman add script_harness`) because a
|
|
39
|
+
# narrow, purpose-built agent wants one shell shape, not three.
|
|
40
|
+
"repl_harness" => {
|
|
41
|
+
source: "harness/repl.rb",
|
|
42
|
+
dest: "harness/repl.rb",
|
|
38
43
|
role: :owned,
|
|
39
|
-
description: "The
|
|
44
|
+
description: "The REPL archetype: a human drives the loop — yours from day one; lyman never updates it"
|
|
45
|
+
},
|
|
46
|
+
"daemon_harness" => {
|
|
47
|
+
source: "harness/daemon.rb",
|
|
48
|
+
dest: "harness/daemon.rb",
|
|
49
|
+
role: :owned,
|
|
50
|
+
optional: true,
|
|
51
|
+
description: "The daemon archetype: launch once, loop on an inbound event stream indefinitely"
|
|
52
|
+
},
|
|
53
|
+
"script_harness" => {
|
|
54
|
+
source: "harness/script.rb",
|
|
55
|
+
dest: "harness/script.rb",
|
|
56
|
+
role: :owned,
|
|
57
|
+
optional: true,
|
|
58
|
+
description: "The script archetype: take one work item at launch, process it, halt"
|
|
59
|
+
},
|
|
60
|
+
# The repl's display layer, one artifact per widget so ownership —
|
|
61
|
+
# and any drift `lyman diff` reports — stays per-file, not per-blob.
|
|
62
|
+
"repl_style" => {
|
|
63
|
+
source: "harness/repl/style.rb",
|
|
64
|
+
dest: "harness/repl/style.rb",
|
|
65
|
+
role: :owned,
|
|
66
|
+
description: "Terminal styling codes and the gray() helper shared by the repl display"
|
|
67
|
+
},
|
|
68
|
+
"think_filter" => {
|
|
69
|
+
source: "harness/repl/think_filter.rb",
|
|
70
|
+
dest: "harness/repl/think_filter.rb",
|
|
71
|
+
role: :owned,
|
|
72
|
+
description: "Streams a dim preview of <think> blocks, then elides the rest"
|
|
73
|
+
},
|
|
74
|
+
"wait_spinner" => {
|
|
75
|
+
source: "harness/repl/wait_spinner.rb",
|
|
76
|
+
dest: "harness/repl/wait_spinner.rb",
|
|
77
|
+
role: :owned,
|
|
78
|
+
description: "Background spinner for the silence before the first streamed token"
|
|
79
|
+
},
|
|
80
|
+
"round_printer" => {
|
|
81
|
+
source: "harness/repl/round_printer.rb",
|
|
82
|
+
dest: "harness/repl/round_printer.rb",
|
|
83
|
+
role: :owned,
|
|
84
|
+
description: "Streams one round to the terminal: spinner, model label, think preview, reply"
|
|
85
|
+
},
|
|
86
|
+
"tool_printer" => {
|
|
87
|
+
source: "harness/repl/tool_printer.rb",
|
|
88
|
+
dest: "harness/repl/tool_printer.rb",
|
|
89
|
+
role: :owned,
|
|
90
|
+
description: "Prints tool calls on the way in, summarized results on the way out"
|
|
40
91
|
},
|
|
41
92
|
"claude_md" => {
|
|
42
93
|
source: "templates/CLAUDE.md",
|
|
@@ -103,9 +154,9 @@ module Lyman
|
|
|
103
154
|
ARTIFACTS.select { |_, spec| spec[:role] == :managed }
|
|
104
155
|
end
|
|
105
156
|
|
|
106
|
-
# What `new` plants: everything except opt-in artifacts
|
|
107
|
-
#
|
|
108
|
-
# CLAUDE.md
|
|
157
|
+
# What `new` plants: everything except opt-in artifacts — alternates a
|
|
158
|
+
# fresh scaffold shouldn't presume (the daemon and script archetypes,
|
|
159
|
+
# the skill variant of CLAUDE.md). Reach them with `lyman add`.
|
|
109
160
|
def self.default
|
|
110
161
|
ARTIFACTS.reject { |_, spec| spec[:optional] }
|
|
111
162
|
end
|
data/lib/lyman/cli/version.rb
CHANGED
data/lib/lyman/conversation.rb
CHANGED
|
@@ -3,63 +3,64 @@ module Lyman
|
|
|
3
3
|
# conversation so far, plus the control data workers consult to decide
|
|
4
4
|
# *whether* to act (never *which of several things* to do).
|
|
5
5
|
#
|
|
6
|
+
# An immutable value. Shifty 0.6 deep-freezes every value at a worker
|
|
7
|
+
# boundary (the :frozen handoff policy), so change is expressed as new
|
|
8
|
+
# values: each with_* method returns a new Conversation and leaves the
|
|
9
|
+
# receiver untouched. Data#with structurally shares the unchanged
|
|
10
|
+
# members — safe precisely because handed-off values are frozen.
|
|
11
|
+
#
|
|
6
12
|
# Messages use string keys throughout, for clean round-tripping with
|
|
7
13
|
# OpenAI-compatible wire formats.
|
|
8
|
-
class Conversation
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def initialize(system_prompt: nil, max_rounds: 10)
|
|
13
|
-
@messages = []
|
|
14
|
-
@messages << {"role" => "system", "content" => system_prompt} if system_prompt
|
|
15
|
-
@rounds = 0
|
|
16
|
-
@max_rounds = max_rounds
|
|
17
|
-
@finished = false
|
|
14
|
+
class Conversation < Data.define(:messages, :rounds, :max_rounds, :finished)
|
|
15
|
+
def initialize(system_prompt: nil, messages: nil, rounds: 0, max_rounds: 10, finished: false)
|
|
16
|
+
messages ||= system_prompt ? [{"role" => "system", "content" => system_prompt}] : []
|
|
17
|
+
super(messages: messages, rounds: rounds, max_rounds: max_rounds, finished: finished)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
def with_user_message(text)
|
|
21
|
+
with(
|
|
22
|
+
messages: messages + [{"role" => "user", "content" => text}],
|
|
23
|
+
rounds: 0,
|
|
24
|
+
finished: false
|
|
25
|
+
)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# A round *is* one model reply, so the counter lives here rather than
|
|
29
|
+
# in the transport worker — swap the transport and the runaway guard
|
|
30
|
+
# still can't be forgotten.
|
|
31
|
+
def with_assistant_message(message)
|
|
32
|
+
with(messages: messages + [message], rounds: rounds + 1)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
|
-
def
|
|
33
|
-
|
|
35
|
+
def with_tool_result(tool_call_id, content)
|
|
36
|
+
with(messages: messages + [{
|
|
34
37
|
"role" => "tool",
|
|
35
38
|
"tool_call_id" => tool_call_id,
|
|
36
39
|
"content" => content
|
|
37
|
-
}
|
|
38
|
-
self
|
|
40
|
+
}])
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def pending_tool_calls
|
|
42
|
-
last =
|
|
44
|
+
last = messages.last
|
|
43
45
|
return [] unless last && last["role"] == "assistant"
|
|
44
46
|
last["tool_calls"] || []
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def last_assistant_content
|
|
48
|
-
message =
|
|
50
|
+
message = messages.rfind { |m| m["role"] == "assistant" }
|
|
49
51
|
message && message["content"]
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
def finish
|
|
53
|
-
|
|
54
|
-
self
|
|
54
|
+
def finish
|
|
55
|
+
with(finished: true)
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def finished?
|
|
58
|
-
|
|
59
|
+
finished
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def runaway?
|
|
62
|
-
|
|
63
|
+
rounds >= max_rounds
|
|
63
64
|
end
|
|
64
65
|
end
|
|
65
66
|
end
|
|
@@ -8,8 +8,8 @@ module Lyman
|
|
|
8
8
|
extend Shifty::DSL
|
|
9
9
|
|
|
10
10
|
# Relay worker: sends the conversation to an OpenAI-compatible chat
|
|
11
|
-
# completions endpoint and
|
|
12
|
-
# include tool calls)
|
|
11
|
+
# completions endpoint and hands off a new conversation with the
|
|
12
|
+
# assistant's reply (which may include tool calls) appended.
|
|
13
13
|
#
|
|
14
14
|
# Pass an on_delta callable to stream: it receives each content
|
|
15
15
|
# fragment as it arrives. The worker still returns the conversation
|
|
@@ -42,9 +42,7 @@ module Lyman
|
|
|
42
42
|
fetch_message(http, uri, payload)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
conversation.
|
|
46
|
-
conversation.rounds += 1
|
|
47
|
-
conversation
|
|
45
|
+
conversation.with_assistant_message(message)
|
|
48
46
|
end
|
|
49
47
|
end
|
|
50
48
|
|