lyman 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +161 -0
- data/docs/design/circuit-pattern.md +167 -0
- data/docs/design/deployment.md +195 -0
- data/docs/vision.md +230 -0
- data/exe/lyman +4 -0
- data/harness/chat.rb +244 -0
- data/lib/lyman/cli/commands/add.rb +61 -0
- data/lib/lyman/cli/commands/diff.rb +57 -0
- data/lib/lyman/cli/commands/doctor.rb +122 -0
- data/lib/lyman/cli/commands/eject.rb +53 -0
- data/lib/lyman/cli/commands/new.rb +72 -0
- data/lib/lyman/cli/commands/update.rb +154 -0
- data/lib/lyman/cli/doctor_check.rb +97 -0
- data/lib/lyman/cli/manifest.rb +113 -0
- data/lib/lyman/cli/planter.rb +40 -0
- data/lib/lyman/cli/registry.rb +98 -0
- data/lib/lyman/cli/version.rb +7 -0
- data/lib/lyman/cli.rb +100 -0
- data/lib/lyman/conversation.rb +65 -0
- data/lib/lyman/workers/chat_completion.rb +154 -0
- data/lib/lyman/workers/tool_execution.rb +47 -0
- data/lib/lyman.rb +8 -0
- data/templates/CLAUDE.md +57 -0
- data/templates/Gemfile +4 -0
- data/templates/gitignore +5 -0
- data/templates/lib_lyman.rb +8 -0
- metadata +111 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module Lyman
|
|
2
|
+
module CLI
|
|
3
|
+
# What lyman installs. Everything the generator plants into a client
|
|
4
|
+
# project is declared here — this list, not directory layout, is the
|
|
5
|
+
# boundary between what lyman *does* (lib/lyman/cli) and what lyman
|
|
6
|
+
# *installs* (everything named below).
|
|
7
|
+
module Registry
|
|
8
|
+
GEM_ROOT = File.expand_path("../../..", __dir__)
|
|
9
|
+
|
|
10
|
+
ARTIFACTS = {
|
|
11
|
+
"lyman_entry" => {
|
|
12
|
+
source: "templates/lib_lyman.rb",
|
|
13
|
+
dest: "lib/lyman.rb",
|
|
14
|
+
role: :managed,
|
|
15
|
+
description: "Library entry point; requires shifty and every planted module"
|
|
16
|
+
},
|
|
17
|
+
"conversation" => {
|
|
18
|
+
source: "lib/lyman/conversation.rb",
|
|
19
|
+
dest: "lib/lyman/conversation.rb",
|
|
20
|
+
role: :managed,
|
|
21
|
+
description: "The item that flows through pipelines"
|
|
22
|
+
},
|
|
23
|
+
"chat_completion" => {
|
|
24
|
+
source: "lib/lyman/workers/chat_completion.rb",
|
|
25
|
+
dest: "lib/lyman/workers/chat_completion.rb",
|
|
26
|
+
role: :managed,
|
|
27
|
+
description: "Relay worker: OpenAI-compatible chat completions (streaming + blocking)"
|
|
28
|
+
},
|
|
29
|
+
"tool_execution" => {
|
|
30
|
+
source: "lib/lyman/workers/tool_execution.rb",
|
|
31
|
+
dest: "lib/lyman/workers/tool_execution.rb",
|
|
32
|
+
role: :managed,
|
|
33
|
+
description: "Relay worker: executes pending tool calls"
|
|
34
|
+
},
|
|
35
|
+
"harness" => {
|
|
36
|
+
source: "harness/chat.rb",
|
|
37
|
+
dest: "harness/chat.rb",
|
|
38
|
+
role: :owned,
|
|
39
|
+
description: "The wiring script — yours from day one; lyman never updates it"
|
|
40
|
+
},
|
|
41
|
+
"claude_md" => {
|
|
42
|
+
source: "templates/CLAUDE.md",
|
|
43
|
+
dest: "CLAUDE.md",
|
|
44
|
+
role: :owned,
|
|
45
|
+
description: "Guidance for coding agents working in this project"
|
|
46
|
+
},
|
|
47
|
+
"gemfile" => {
|
|
48
|
+
source: "templates/Gemfile",
|
|
49
|
+
dest: "Gemfile",
|
|
50
|
+
role: :owned,
|
|
51
|
+
description: "Client dependencies: shifty (and ostruct for ruby >= 4)"
|
|
52
|
+
},
|
|
53
|
+
"gitignore" => {
|
|
54
|
+
source: "templates/gitignore",
|
|
55
|
+
dest: ".gitignore",
|
|
56
|
+
role: :owned,
|
|
57
|
+
description: "A minimal starter .gitignore (.lyman/ stays tracked on purpose)"
|
|
58
|
+
}
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
def self.fetch(name)
|
|
62
|
+
ARTIFACTS.fetch(name) do
|
|
63
|
+
valid = ARTIFACTS.keys.join(", ")
|
|
64
|
+
raise Thor::Error, "Unknown artifact #{name.inspect}. Valid artifacts: #{valid}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Commands accept an artifact name or a project-relative path — you
|
|
69
|
+
# shouldn't have to remember the token while looking at the file.
|
|
70
|
+
# Resolution order: registry name, then the path recorded in this
|
|
71
|
+
# project's manifest (authoritative for where the file actually is),
|
|
72
|
+
# then the registry's dest (for artifacts not yet planted).
|
|
73
|
+
def self.resolve(token, manifest: nil)
|
|
74
|
+
return token if ARTIFACTS.key?(token)
|
|
75
|
+
|
|
76
|
+
path = token.delete_prefix("./")
|
|
77
|
+
if manifest
|
|
78
|
+
name, _entry = manifest.artifacts.find { |_, entry| entry["path"] == path }
|
|
79
|
+
return name if name
|
|
80
|
+
end
|
|
81
|
+
name, _spec = ARTIFACTS.find { |_, spec| spec[:dest] == path }
|
|
82
|
+
return name if name
|
|
83
|
+
|
|
84
|
+
valid = ARTIFACTS.keys.join(", ")
|
|
85
|
+
raise Thor::Error, "Unknown artifact #{token.inspect}. " \
|
|
86
|
+
"Give an artifact name (#{valid}) or a planted path (e.g. lib/lyman/conversation.rb)."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.managed
|
|
90
|
+
ARTIFACTS.select { |_, spec| spec[:role] == :managed }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.source_path(spec, source_root: GEM_ROOT)
|
|
94
|
+
File.join(source_root, spec[:source])
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/lyman/cli.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
|
|
3
|
+
require_relative "cli/version"
|
|
4
|
+
require_relative "cli/registry"
|
|
5
|
+
require_relative "cli/manifest"
|
|
6
|
+
require_relative "cli/planter"
|
|
7
|
+
require_relative "cli/commands/new"
|
|
8
|
+
require_relative "cli/commands/add"
|
|
9
|
+
require_relative "cli/commands/update"
|
|
10
|
+
require_relative "cli/commands/eject"
|
|
11
|
+
require_relative "cli/commands/diff"
|
|
12
|
+
require_relative "cli/commands/doctor"
|
|
13
|
+
|
|
14
|
+
module Lyman
|
|
15
|
+
module CLI
|
|
16
|
+
# The legible table of contents for every lyman command. Root wires
|
|
17
|
+
# Thor's argument parsing to a plain object per command
|
|
18
|
+
# (lib/lyman/cli/commands/*.rb) and nothing else — no command grows its
|
|
19
|
+
# body here.
|
|
20
|
+
#
|
|
21
|
+
# Command contract: every command class is built as
|
|
22
|
+
# `Commands::X.new(thor, source_root:).call(*args)`.
|
|
23
|
+
# - `thor` is this Root instance, passed through so a command can use
|
|
24
|
+
# Thor's UI helpers (`thor.yes?`, `thor.say`) without inheriting from
|
|
25
|
+
# Thor itself — commands are plain objects, not Thor subclasses.
|
|
26
|
+
# - `source_root:` is where artifacts are read from (the gem tree by
|
|
27
|
+
# default; overridable via LYMAN_SOURCE_ROOT so tests can point it at
|
|
28
|
+
# a fixture that simulates a newer lyman release).
|
|
29
|
+
# - `#call`'s positional args are whatever the command needs (an
|
|
30
|
+
# artifact/project name, or nothing). Project-root discovery
|
|
31
|
+
# (Manifest.find!) happens inside each command that needs it — `new`
|
|
32
|
+
# is the one command that doesn't have a project root yet.
|
|
33
|
+
class Root < Thor
|
|
34
|
+
def self.exit_on_failure?
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "new NAME", "Scaffold a new lyman project at NAME"
|
|
39
|
+
def new(name)
|
|
40
|
+
Commands::New.new(self, source_root: source_root).call(name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc "add ARTIFACT", "Plant an artifact (by name or path) this project doesn't have yet"
|
|
44
|
+
method_option :force, type: :boolean, default: false,
|
|
45
|
+
desc: "Skip the confirmation prompt when re-adding an ejected artifact, " \
|
|
46
|
+
"or overwrite an untracked file at the destination"
|
|
47
|
+
def add(artifact)
|
|
48
|
+
Commands::Add.new(self, source_root: source_root).call(artifact, force: options[:force])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
desc "update", "Refresh managed artifacts from the current lyman release"
|
|
52
|
+
def update
|
|
53
|
+
Commands::Update.new(self, source_root: source_root).call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc "eject ARTIFACT", "Take ownership of a managed artifact (by name or path)"
|
|
57
|
+
def eject(artifact)
|
|
58
|
+
Commands::Eject.new(self, source_root: source_root).call(artifact)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
desc "diff ARTIFACT", "Show local and upstream changes for an artifact (by name or path)"
|
|
62
|
+
def diff(artifact)
|
|
63
|
+
Commands::Diff.new(self, source_root: source_root).call(artifact)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
desc "doctor", "Smoke-test that the planted pipeline still runs"
|
|
67
|
+
def doctor
|
|
68
|
+
Commands::Doctor.new(self, source_root: source_root).call
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
desc "list", "List every artifact lyman knows how to install"
|
|
72
|
+
def list
|
|
73
|
+
manifest_root = Manifest.find
|
|
74
|
+
manifest = Manifest.load(manifest_root) if manifest_root
|
|
75
|
+
|
|
76
|
+
Registry::ARTIFACTS.each do |name, spec|
|
|
77
|
+
status =
|
|
78
|
+
if manifest
|
|
79
|
+
entry = manifest.artifact(name)
|
|
80
|
+
entry ? entry["status"] : "not planted"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
line = "#{name} (#{spec[:role]}) #{spec[:dest]}#{" — #{status}" if status} — #{spec[:description]}"
|
|
84
|
+
say line
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
desc "version", "Print the lyman gem version"
|
|
89
|
+
def version
|
|
90
|
+
say VERSION
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def source_root
|
|
96
|
+
ENV.fetch("LYMAN_SOURCE_ROOT", Registry::GEM_ROOT)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Lyman
|
|
2
|
+
# The item that flows through the spine and the circuit: the whole
|
|
3
|
+
# conversation so far, plus the control data workers consult to decide
|
|
4
|
+
# *whether* to act (never *which of several things* to do).
|
|
5
|
+
#
|
|
6
|
+
# Messages use string keys throughout, for clean round-tripping with
|
|
7
|
+
# OpenAI-compatible wire formats.
|
|
8
|
+
class Conversation
|
|
9
|
+
attr_reader :messages
|
|
10
|
+
attr_accessor :rounds, :max_rounds
|
|
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
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add_user_message(text)
|
|
21
|
+
@messages << {"role" => "user", "content" => text}
|
|
22
|
+
@finished = false
|
|
23
|
+
@rounds = 0
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_assistant_message(message)
|
|
28
|
+
@messages << message
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add_tool_result(tool_call_id, content)
|
|
33
|
+
@messages << {
|
|
34
|
+
"role" => "tool",
|
|
35
|
+
"tool_call_id" => tool_call_id,
|
|
36
|
+
"content" => content
|
|
37
|
+
}
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pending_tool_calls
|
|
42
|
+
last = @messages.last
|
|
43
|
+
return [] unless last && last["role"] == "assistant"
|
|
44
|
+
last["tool_calls"] || []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def last_assistant_content
|
|
48
|
+
message = @messages.rfind { |m| m["role"] == "assistant" }
|
|
49
|
+
message && message["content"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def finish!
|
|
53
|
+
@finished = true
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def finished?
|
|
58
|
+
@finished
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def runaway?
|
|
62
|
+
@rounds >= @max_rounds
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require "shifty"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Lyman
|
|
7
|
+
module Workers
|
|
8
|
+
extend Shifty::DSL
|
|
9
|
+
|
|
10
|
+
# Relay worker: sends the conversation to an OpenAI-compatible chat
|
|
11
|
+
# completions endpoint and appends the assistant's reply (which may
|
|
12
|
+
# include tool calls). Increments the conversation's round counter.
|
|
13
|
+
#
|
|
14
|
+
# Pass an on_delta callable to stream: it receives each content
|
|
15
|
+
# fragment as it arrives. The worker still returns the conversation
|
|
16
|
+
# with the fully-assembled message appended, so the circuit sees no
|
|
17
|
+
# difference between streamed and unstreamed rounds.
|
|
18
|
+
#
|
|
19
|
+
# Servers disagree about where thinking goes: some put <think> tags
|
|
20
|
+
# inline in the content, others (e.g. Ollama) stream a separate
|
|
21
|
+
# reasoning field. The worker normalizes the latter to the former,
|
|
22
|
+
# so on_delta always sees one convention: <think>...</think> inline.
|
|
23
|
+
# The raw reasoning text still lands on the message unaltered.
|
|
24
|
+
#
|
|
25
|
+
# This is the only part of lyman that knows HTTP exists.
|
|
26
|
+
def self.chat_completion(base_url:, model:, tools: nil, read_timeout: 300, on_delta: nil)
|
|
27
|
+
uri = URI("#{base_url.chomp("/")}/chat/completions")
|
|
28
|
+
|
|
29
|
+
relay_worker do |conversation|
|
|
30
|
+
payload = {"model" => model, "messages" => wire_messages(conversation.messages)}
|
|
31
|
+
payload["tools"] = tools if tools && !tools.empty?
|
|
32
|
+
payload["stream"] = true if on_delta
|
|
33
|
+
|
|
34
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
35
|
+
http.use_ssl = (uri.scheme == "https")
|
|
36
|
+
http.read_timeout = read_timeout
|
|
37
|
+
|
|
38
|
+
message =
|
|
39
|
+
if on_delta
|
|
40
|
+
stream_message(http, uri, payload, on_delta)
|
|
41
|
+
else
|
|
42
|
+
fetch_message(http, uri, payload)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
conversation.add_assistant_message(message)
|
|
46
|
+
conversation.rounds += 1
|
|
47
|
+
conversation
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The conversation keeps each message's reasoning (it's useful to
|
|
52
|
+
# observability), but it never rides back to the model: providers
|
|
53
|
+
# either ignore it, reject it outright (DeepSeek), or would burn
|
|
54
|
+
# context re-reading thoughts the model already finished thinking.
|
|
55
|
+
def self.wire_messages(messages)
|
|
56
|
+
messages.map do |message|
|
|
57
|
+
message.reject { |key, _| key == "reasoning" || key == "reasoning_content" }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.fetch_message(http, uri, payload)
|
|
62
|
+
response = http.post(
|
|
63
|
+
uri.path,
|
|
64
|
+
JSON.generate(payload),
|
|
65
|
+
"Content-Type" => "application/json"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
69
|
+
raise "chat completion failed (#{response.code}): #{response.body}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
message = JSON.parse(response.body).dig("choices", 0, "message")
|
|
73
|
+
raise "chat completion response had no message: #{response.body}" unless message
|
|
74
|
+
message
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.stream_message(http, uri, payload, on_delta)
|
|
78
|
+
request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
|
|
79
|
+
request.body = JSON.generate(payload)
|
|
80
|
+
|
|
81
|
+
message = {"role" => "assistant", "content" => nil}
|
|
82
|
+
tool_calls = {}
|
|
83
|
+
state = {thinking: false}
|
|
84
|
+
buffer = +""
|
|
85
|
+
|
|
86
|
+
http.request(request) do |response|
|
|
87
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
88
|
+
raise "chat completion failed (#{response.code}): #{response.read_body}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
response.read_body do |chunk|
|
|
92
|
+
buffer << chunk
|
|
93
|
+
# SSE events arrive as "data: {json}" lines, but chunks don't
|
|
94
|
+
# align with line boundaries.
|
|
95
|
+
while (newline = buffer.index("\n"))
|
|
96
|
+
line = buffer.slice!(0..newline).strip
|
|
97
|
+
next unless line.start_with?("data:")
|
|
98
|
+
|
|
99
|
+
data = line.delete_prefix("data:").strip
|
|
100
|
+
next if data == "[DONE]"
|
|
101
|
+
|
|
102
|
+
delta = JSON.parse(data).dig("choices", 0, "delta")
|
|
103
|
+
apply_delta(message, tool_calls, delta, on_delta, state) if delta
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
on_delta.call("</think>") if state[:thinking] # reasoning ran to stream end
|
|
109
|
+
unless tool_calls.empty?
|
|
110
|
+
message["tool_calls"] = tool_calls.keys.sort.map { |index| tool_calls[index] }
|
|
111
|
+
end
|
|
112
|
+
message
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Content deltas concatenate; tool-call deltas arrive as fragments
|
|
116
|
+
# keyed by index, with the arguments string dribbling in over many
|
|
117
|
+
# chunks. Reasoning-field deltas stream to on_delta wrapped in
|
|
118
|
+
# synthesized <think> tags (see chat_completion).
|
|
119
|
+
def self.apply_delta(message, tool_calls, delta, on_delta, state)
|
|
120
|
+
reasoning = delta["reasoning"] || delta["reasoning_content"]
|
|
121
|
+
if reasoning && !reasoning.empty?
|
|
122
|
+
unless state[:thinking]
|
|
123
|
+
state[:thinking] = true
|
|
124
|
+
on_delta.call("<think>")
|
|
125
|
+
end
|
|
126
|
+
message["reasoning"] = (message["reasoning"] || +"") + reasoning
|
|
127
|
+
on_delta.call(reasoning)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if (text = delta["content"]) && !text.empty?
|
|
131
|
+
if state[:thinking]
|
|
132
|
+
state[:thinking] = false
|
|
133
|
+
on_delta.call("</think>")
|
|
134
|
+
end
|
|
135
|
+
message["content"] = (message["content"] || +"") + text
|
|
136
|
+
on_delta.call(text)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
(delta["tool_calls"] || []).each do |fragment|
|
|
140
|
+
entry = tool_calls[fragment["index"]] ||= {
|
|
141
|
+
"id" => nil,
|
|
142
|
+
"type" => "function",
|
|
143
|
+
"function" => {"name" => +"", "arguments" => +""}
|
|
144
|
+
}
|
|
145
|
+
entry["id"] = fragment["id"] if fragment["id"]
|
|
146
|
+
entry["type"] = fragment["type"] if fragment["type"]
|
|
147
|
+
|
|
148
|
+
function = fragment["function"] || {}
|
|
149
|
+
entry["function"]["name"] << function["name"] if function["name"]
|
|
150
|
+
entry["function"]["arguments"] << function["arguments"] if function["arguments"]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "shifty"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Lyman
|
|
5
|
+
module Workers
|
|
6
|
+
extend Shifty::DSL
|
|
7
|
+
|
|
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.
|
|
11
|
+
#
|
|
12
|
+
# +handlers+ is a hash of tool name => callable taking a Hash of
|
|
13
|
+
# arguments and returning something stringable.
|
|
14
|
+
def self.tool_execution(handlers)
|
|
15
|
+
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]
|
|
21
|
+
|
|
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}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
conversation.add_tool_result(tool_call["id"], result)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
conversation
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.parse_arguments(raw)
|
|
41
|
+
return raw if raw.is_a?(Hash)
|
|
42
|
+
JSON.parse(raw.to_s)
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
{}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/lyman.rb
ADDED
data/templates/CLAUDE.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This project was scaffolded by [lyman](https://github.com/joelhelbling/lyman):
|
|
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
|
|
6
|
+
against any OpenAI-compatible chat completions endpoint (Ollama by default).
|
|
7
|
+
|
|
8
|
+
## Running it
|
|
9
|
+
|
|
10
|
+
- `bundle install` — install dependencies (just `shifty` and `ostruct`; this
|
|
11
|
+
project has no runtime dependency on the `lyman` gem itself).
|
|
12
|
+
- `ruby harness/chat.rb` — run the interactive chat harness. Defaults to
|
|
13
|
+
Ollama at `http://localhost:11434/v1`; override with `LYMAN_MODEL` and
|
|
14
|
+
`LYMAN_BASE_URL` env vars.
|
|
15
|
+
|
|
16
|
+
## The managed/owned boundary
|
|
17
|
+
|
|
18
|
+
Everything under the `Lyman::` namespace (`lib/lyman.rb`, `lib/lyman/`) is
|
|
19
|
+
**managed** by the lyman CLI: it was planted here, and `lyman update` can
|
|
20
|
+
refresh it from newer lyman releases. Treat it as a library to extend, not a
|
|
21
|
+
file to hand-edit — if you need to change one, run `lyman eject <name>` first,
|
|
22
|
+
which takes ownership explicitly rather than leaving it silently forked.
|
|
23
|
+
`.lyman/manifest.yml` is the record of what's managed, what's owned, and what
|
|
24
|
+
you've ejected; commit it.
|
|
25
|
+
|
|
26
|
+
`harness/chat.rb` and everything outside `Lyman::` is **owned** — yours from
|
|
27
|
+
day one, never touched by `lyman update`. Put your own workers in your own
|
|
28
|
+
namespace or directory, not inside `lib/lyman/`.
|
|
29
|
+
|
|
30
|
+
## Four load-bearing facts
|
|
31
|
+
|
|
32
|
+
1. **The nil-source footgun.** In shifty, a source returning `nil` ends the
|
|
33
|
+
stream permanently. If you're driving a pipeline off a queue, enqueue
|
|
34
|
+
before you shift — never pull a source worker while its queue is empty.
|
|
35
|
+
|
|
36
|
+
2. **The runaway-turn guard.** The model⇄tool circuit is bounded by
|
|
37
|
+
`Conversation#runaway?` / `max_rounds`. Keep that guard intact when
|
|
38
|
+
rewiring the circuit; without it, a model that keeps calling tools never
|
|
39
|
+
lets a turn end.
|
|
40
|
+
|
|
41
|
+
3. **Item-as-control discipline.** An item may tell a worker *whether* to
|
|
42
|
+
act, never *which of several things* to do. A worker that switches
|
|
43
|
+
between jobs based on item state is the anti-pattern to avoid
|
|
44
|
+
(multi-way dispatch) — split it into stages, or use a splitter, instead.
|
|
45
|
+
|
|
46
|
+
4. **Wire vs. conversation.** Reasoning/thinking content stays on messages in
|
|
47
|
+
the `Conversation` for observability, but `Workers.wire_messages` strips
|
|
48
|
+
it before anything goes out over the wire. Preserve that separation if
|
|
49
|
+
you touch message handling.
|
|
50
|
+
|
|
51
|
+
## Other conventions
|
|
52
|
+
|
|
53
|
+
- `lyman doctor` smoke-tests the pipeline end to end against a stub
|
|
54
|
+
transport — run it after `lyman update` or whenever something feels off.
|
|
55
|
+
- `lyman list` shows what's managed, owned, or ejected in this project.
|
|
56
|
+
- Messages use string keys throughout, matching the OpenAI-compatible wire
|
|
57
|
+
format — no symbol-key message hashes.
|
data/templates/Gemfile
ADDED
data/templates/gitignore
ADDED
metadata
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lyman
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Joel Helbling
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: shifty
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ostruct
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: thor
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
email:
|
|
55
|
+
- joel.helbling@gmail.com
|
|
56
|
+
executables:
|
|
57
|
+
- lyman
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- docs/design/circuit-pattern.md
|
|
64
|
+
- docs/design/deployment.md
|
|
65
|
+
- docs/vision.md
|
|
66
|
+
- exe/lyman
|
|
67
|
+
- harness/chat.rb
|
|
68
|
+
- lib/lyman.rb
|
|
69
|
+
- lib/lyman/cli.rb
|
|
70
|
+
- lib/lyman/cli/commands/add.rb
|
|
71
|
+
- lib/lyman/cli/commands/diff.rb
|
|
72
|
+
- lib/lyman/cli/commands/doctor.rb
|
|
73
|
+
- lib/lyman/cli/commands/eject.rb
|
|
74
|
+
- lib/lyman/cli/commands/new.rb
|
|
75
|
+
- lib/lyman/cli/commands/update.rb
|
|
76
|
+
- lib/lyman/cli/doctor_check.rb
|
|
77
|
+
- lib/lyman/cli/manifest.rb
|
|
78
|
+
- lib/lyman/cli/planter.rb
|
|
79
|
+
- lib/lyman/cli/registry.rb
|
|
80
|
+
- lib/lyman/cli/version.rb
|
|
81
|
+
- lib/lyman/conversation.rb
|
|
82
|
+
- lib/lyman/workers/chat_completion.rb
|
|
83
|
+
- lib/lyman/workers/tool_execution.rb
|
|
84
|
+
- templates/CLAUDE.md
|
|
85
|
+
- templates/Gemfile
|
|
86
|
+
- templates/gitignore
|
|
87
|
+
- templates/lib_lyman.rb
|
|
88
|
+
homepage: https://github.com/joelhelbling/lyman
|
|
89
|
+
licenses:
|
|
90
|
+
- MIT
|
|
91
|
+
metadata:
|
|
92
|
+
homepage_uri: https://github.com/joelhelbling/lyman
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '3.2'
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 4.0.10
|
|
108
|
+
specification_version: 4
|
|
109
|
+
summary: A composable agentic harness, delivered as code you own — a pure generator
|
|
110
|
+
in the shadcn/ui mold, built on shifty
|
|
111
|
+
test_files: []
|