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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d84a3588f59995354f5545ce07169904476a2ed0cff059405fd53f6af144cfab
|
|
4
|
+
data.tar.gz: ec3fcc387412a1b0281297ae725f9f0a80e9f7f4dabb49cd6255cb2754554d21
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b89f9cbfa5a5529d00f5a8394e2eafcc0abc41ee6e4d96f218ce6637523de5113e8516040466c212a71d335963966bbb91f8eea7eb368efa738c6f2f2156546c
|
|
7
|
+
data.tar.gz: 359637ee13e913c77dfd144fde5e24763a6a2c4bce75cdac8677c033e13789a08223dcd79fe95065b4cdd15b33c86cdcde88b7295d339e13a35aa5a5f27d9cae
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
The harness archetypes and immutable conversation release. Lyman now ships
|
|
6
|
+
three archetype harnesses — one circuit, three shells — and adopts shifty
|
|
7
|
+
0.6's frozen handoffs: `Conversation` became an immutable value. This is a
|
|
8
|
+
breaking change for planted projects; see the migration notes below.
|
|
9
|
+
|
|
10
|
+
### Breaking changes
|
|
11
|
+
|
|
12
|
+
- **`Conversation` is an immutable value** (a `Data` subclass). Change is
|
|
13
|
+
expressed as new values: `with_user_message`, `with_assistant_message`,
|
|
14
|
+
`with_tool_result`, and `finish` replace `add_user_message`,
|
|
15
|
+
`add_assistant_message`, `add_tool_result`, and `finish!`, each returning
|
|
16
|
+
a new conversation. Shells rebind instead of mutating
|
|
17
|
+
(`conversation = pipeline.shift`). The round counter moved into
|
|
18
|
+
`with_assistant_message` — a round *is* one model reply — so a swapped-in
|
|
19
|
+
transport can no longer forget the runaway guard. Reasoning and design in
|
|
20
|
+
`docs/design/immutable-conversation.md`.
|
|
21
|
+
- **shifty ~> 0.6 is required** (was 0.5). Shifty's default handoff policy
|
|
22
|
+
deeply freezes every value at a worker boundary; a worker that mutates
|
|
23
|
+
its input raises `Shifty::PolicyViolation`. The planted
|
|
24
|
+
`chat_completion` and `tool_execution` workers are rewritten
|
|
25
|
+
non-destructively and declare no policy escapes.
|
|
26
|
+
- **The chat harness is renamed to the repl harness**: `harness/chat.rb` →
|
|
27
|
+
`harness/repl.rb`, display layer `harness/chat/` → `harness/repl/`.
|
|
28
|
+
Registry artifact names follow: `harness` → `repl_harness`,
|
|
29
|
+
`chat_style` → `repl_style`. Existing projects are unaffected in place —
|
|
30
|
+
harness artifacts are owned, `lyman update` never touches them, and
|
|
31
|
+
manifest entries unknown to this release are left alone — but a fresh
|
|
32
|
+
`lyman add` plants the new names at the new paths.
|
|
33
|
+
|
|
34
|
+
### Migrating a planted project
|
|
35
|
+
|
|
36
|
+
1. `bundle update shifty` (and take `gem "shifty", "~> 0.6"` in your
|
|
37
|
+
Gemfile).
|
|
38
|
+
2. `lyman update` — refreshes the managed `conversation`,
|
|
39
|
+
`chat_completion`, and `tool_execution` modules (halting first, as
|
|
40
|
+
always, if you've modified them).
|
|
41
|
+
3. Port your own harness and workers: `add_*` → `with_*`, `finish!` →
|
|
42
|
+
`finish`, and rebind shell state to what the pipeline returns. A
|
|
43
|
+
`Shifty::PolicyViolation` names the offending worker if you miss one.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **The daemon archetype** (`lyman add daemon_harness`): launch once, loop
|
|
48
|
+
indefinitely on an inbound event stream — shipped as a stdlib-only,
|
|
49
|
+
line-per-event TCP listener (port 1216, the Lyman-alpha wavelength in
|
|
50
|
+
Ångströms), fresh conversation per event, tool calls logged by a spliced
|
|
51
|
+
side worker.
|
|
52
|
+
- **The script archetype** (`lyman add script_harness`): work item from
|
|
53
|
+
ARGV or stdin at launch, one enqueue and one shift, final answer on
|
|
54
|
+
stdout, halt. No loop in the shell at all — repetition lives in the
|
|
55
|
+
pipeline.
|
|
56
|
+
- **Opt-in artifacts as a registry concept matured**: `lyman new` plants
|
|
57
|
+
the repl; the daemon and script archetypes are a `lyman add` away, so a
|
|
58
|
+
narrow, purpose-built agent gets one shell shape, not three.
|
|
59
|
+
- **The repl's display layer, one owned artifact per widget**
|
|
60
|
+
(`repl_style`, `think_filter`, `wait_spinner`, `round_printer`,
|
|
61
|
+
`tool_printer`), so ownership — and any drift `lyman diff` reports —
|
|
62
|
+
stays per-file. Its cli-ui/reline dependencies stay confined to
|
|
63
|
+
`harness/repl/`.
|
|
64
|
+
- **Design notes**: `docs/design/harness-archetypes.md` (one circuit,
|
|
65
|
+
three shells) and `docs/design/immutable-conversation.md` (the shifty
|
|
66
|
+
0.6 adaptation).
|
|
67
|
+
- **A documentation wiki** — [guided tour on GitHub](https://github.com/joelhelbling/lyman/wiki):
|
|
68
|
+
Getting Started, Core Concepts, the archetypes, and the generator CLI.
|
|
69
|
+
Sourced from `wiki/` in this repo and published with `wiki/publish.sh`.
|
|
70
|
+
- Scaffolded guidance (`CLAUDE.md` / the `claude_skill` variant) now
|
|
71
|
+
carries five load-bearing facts — frozen handoffs joined the list — and
|
|
72
|
+
describes the archetypes.
|
|
73
|
+
|
|
74
|
+
## 0.2.1
|
|
75
|
+
|
|
76
|
+
- The chat harness display grew a proper face: cli-ui styling (colored
|
|
77
|
+
labels, glyphs), reline line editing and history at the prompt, a wait
|
|
78
|
+
spinner for prefill silence, a streamed dim preview of `<think>` blocks,
|
|
79
|
+
and one-line tool-call/result reporting. The scaffolded Gemfile gains
|
|
80
|
+
`cli-ui` and `reline` (display-layer only; drop them if you restyle).
|
|
81
|
+
|
|
82
|
+
## 0.2.0
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- **`claude_skill` artifact** (`lyman add claude_skill`): the scaffolded
|
|
87
|
+
CLAUDE.md guidance packaged as a Claude Code skill at
|
|
88
|
+
`.claude/skills/lyman/SKILL.md`, for projects that already have a
|
|
89
|
+
`CLAUDE.md` lyman shouldn't clobber. First **opt-in** artifact
|
|
90
|
+
(`optional:` in the registry): skipped by `new`, reached with `add`;
|
|
91
|
+
`lyman add claude_md` suggests it when refusing to overwrite an existing
|
|
92
|
+
file. `lyman list` labels opt-in artifacts.
|
|
93
|
+
|
|
94
|
+
## 0.1.0
|
|
95
|
+
|
|
96
|
+
Initial release: lyman as a **pure generator** (the shadcn/ui model) — the
|
|
97
|
+
gem plants legible, manifest-tracked source into client projects and is
|
|
98
|
+
never a runtime dependency.
|
|
99
|
+
|
|
100
|
+
- The plantable library: `Conversation` (the item that flows through
|
|
101
|
+
pipelines) and the `chat_completion` (OpenAI-compatible, streaming +
|
|
102
|
+
blocking) and `tool_execution` workers.
|
|
103
|
+
- The chat harness: the circuit pattern wired as one legible top-level
|
|
104
|
+
script, owned by the user from day one.
|
|
105
|
+
- The generator CLI: `new`, `add`, `update`, `eject`, `diff`, `doctor`,
|
|
106
|
+
`list`, backed by a path-aware manifest (`.lyman/manifest.yml`) with a
|
|
107
|
+
pristine cache, three-tier `update` (pristine/modified/untracked),
|
|
108
|
+
eject-to-own with tombstones and upstream-change advisories, and a
|
|
109
|
+
pipeline smoke test (`doctor`) that needs no model server.
|
data/README.md
CHANGED
|
@@ -14,6 +14,8 @@ less margin for error than the frontier labs. Your one real edge is
|
|
|
14
14
|
> series is also, fittingly, a sequence of discrete lines — like the stages of
|
|
15
15
|
> a pipeline.
|
|
16
16
|
|
|
17
|
+

|
|
18
|
+
|
|
17
19
|
## The idea
|
|
18
20
|
|
|
19
21
|
An agent harness is a pipeline. Not as a metaphor — literally:
|
|
@@ -22,13 +24,13 @@ An agent harness is a pipeline. Not as a metaphor — literally:
|
|
|
22
24
|
pipeline =
|
|
23
25
|
source_worker { rounds.shift } |
|
|
24
26
|
Lyman::Workers.chat_completion(base_url:, model:, tools:) |
|
|
25
|
-
relay_worker { |c| c.
|
|
27
|
+
relay_worker { |c| c.pending_tool_calls.empty? ? c.finish : c } |
|
|
26
28
|
Lyman::Workers.tool_execution(handlers) |
|
|
27
29
|
side_worker { |c| rounds << c unless c.finished? } |
|
|
28
30
|
filter_worker { |c| c.finished? }
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
That's not pseudocode — it's the working heart of [`harness/
|
|
33
|
+
That's not pseudocode — it's the working heart of [`harness/repl.rb`](harness/repl.rb),
|
|
32
34
|
a tool-using chat agent for local models. Every stage is a plain
|
|
33
35
|
[shifty](https://github.com/joelhelbling/shifty) worker. Want logging? Splice
|
|
34
36
|
in a `side_worker` — anywhere. Want a guardrail? A `filter_worker`. Want to
|
|
@@ -58,21 +60,26 @@ support (e.g. `qwen3.5`, `gemma4`, `mistral`).
|
|
|
58
60
|
|
|
59
61
|
```sh
|
|
60
62
|
bundle install
|
|
61
|
-
bundle exec ruby harness/
|
|
63
|
+
bundle exec ruby harness/repl.rb
|
|
62
64
|
```
|
|
63
65
|
|
|
64
66
|
```
|
|
65
67
|
lyman ⇢ gemma4:latest @ http://localhost:11434/v1
|
|
66
68
|
|
|
67
69
|
you> what time is it?
|
|
70
|
+
|
|
71
|
+
gemma4:latest> ✻ The user wants the current time. I have a current_time tool…
|
|
72
|
+
|
|
68
73
|
⚙ current_time {}
|
|
74
|
+
✓ current_time → 2026-07-08 18:13:29 EDT
|
|
69
75
|
|
|
70
|
-
gemma4:latest> It's
|
|
76
|
+
gemma4:latest> It's 6:13 PM on Wednesday, July 8th.
|
|
71
77
|
```
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
The `✻` aside is a streamed preview of the model's thinking; the `⚙` and `✓`
|
|
80
|
+
lines are side workers showing a tool call and its result — and the reply
|
|
81
|
+
after them means two model round-trips completed inside a single pull on the
|
|
82
|
+
pipeline. Point it elsewhere with `LYMAN_BASE_URL` and `LYMAN_MODEL`.
|
|
76
83
|
|
|
77
84
|
## How it works, briefly
|
|
78
85
|
|
|
@@ -84,6 +91,11 @@ the pipeline. Point it elsewhere with `LYMAN_BASE_URL` and `LYMAN_MODEL`.
|
|
|
84
91
|
state plus a process: the conversation, a queue, and a deliberately boring
|
|
85
92
|
loop. A human REPL is one shell; an autonomous email-triager is another.
|
|
86
93
|
Lyman is turn-shaped, not chat-shaped.
|
|
94
|
+
- **Three archetype harnesses prove it.** The same circuit ships inside
|
|
95
|
+
three shells: a **repl** (a human drives the loop), a **daemon** (loop
|
|
96
|
+
forever on an inbound event stream), and a **script** (take one work item
|
|
97
|
+
at launch, process it, halt). Only the shell differs — see
|
|
98
|
+
[docs/design/harness-archetypes.md](docs/design/harness-archetypes.md).
|
|
87
99
|
- **The agentic loop is a circuit, not a special construct.** Shifty pipelines
|
|
88
100
|
are strictly linear, so the model⇄tool cycle is built from stock parts: a
|
|
89
101
|
source reading from a queue, a side worker re-enqueueing unfinished rounds,
|
|
@@ -98,6 +110,27 @@ the pipeline. Point it elsewhere with `LYMAN_BASE_URL` and `LYMAN_MODEL`.
|
|
|
98
110
|
The longer story — mission, principles, architecture decisions, open
|
|
99
111
|
questions — is in [docs/vision.md](docs/vision.md).
|
|
100
112
|
|
|
113
|
+
## Documentation
|
|
114
|
+
|
|
115
|
+
The **[wiki](https://github.com/joelhelbling/lyman/wiki)** is the guided
|
|
116
|
+
tour:
|
|
117
|
+
|
|
118
|
+
- [Getting Started](https://github.com/joelhelbling/lyman/wiki/Getting-Started) —
|
|
119
|
+
scaffold a project and talk to a local model in five minutes
|
|
120
|
+
- [Core Concepts](https://github.com/joelhelbling/lyman/wiki/Core-Concepts) —
|
|
121
|
+
workers, the item, the shell, the circuit, and the sharp edges
|
|
122
|
+
- [Harness Archetypes](https://github.com/joelhelbling/lyman/wiki/Harness-Archetypes) —
|
|
123
|
+
choosing between the [REPL](https://github.com/joelhelbling/lyman/wiki/The-REPL-Archetype),
|
|
124
|
+
[Daemon](https://github.com/joelhelbling/lyman/wiki/The-Daemon-Archetype), and
|
|
125
|
+
[Script](https://github.com/joelhelbling/lyman/wiki/The-Script-Archetype) shapes
|
|
126
|
+
- [The Generator CLI](https://github.com/joelhelbling/lyman/wiki/The-Generator-CLI) —
|
|
127
|
+
`new`, `add`, `update`, `eject`, `diff`, `doctor`, and the manifest
|
|
128
|
+
|
|
129
|
+
The wiki's source of truth is the [`wiki/`](wiki/) directory in this repo —
|
|
130
|
+
documentation changes ride through pull requests, then publish with
|
|
131
|
+
[`wiki/publish.sh`](wiki/publish.sh). For design *intent*, the
|
|
132
|
+
[docs/](docs/) design notes remain authoritative.
|
|
133
|
+
|
|
101
134
|
## Is lyman a good fit for you?
|
|
102
135
|
|
|
103
136
|
**Probably yes, if:**
|
|
@@ -148,11 +181,12 @@ touched at all: it's yours from day one.
|
|
|
148
181
|
|
|
149
182
|
## Status
|
|
150
183
|
|
|
151
|
-
Early and moving. What exists today: the vision, circuit-pattern,
|
|
152
|
-
deployment design docs, the core `Conversation` item,
|
|
153
|
-
tool-execution workers,
|
|
154
|
-
|
|
155
|
-
`
|
|
184
|
+
Early and moving. What exists today: the vision, circuit-pattern,
|
|
185
|
+
harness-archetypes, and deployment design docs, the core `Conversation` item,
|
|
186
|
+
chat-completion and tool-execution workers, the three archetype harnesses
|
|
187
|
+
(repl, daemon, script) working against live local models, and the generator
|
|
188
|
+
CLI (`new` / `add` / `update` / `eject` / `diff` / `doctor` / `list`) with a
|
|
189
|
+
Minitest suite behind it. Published to
|
|
156
190
|
[rubygems.org](https://rubygems.org/gems/lyman) — install it with:
|
|
157
191
|
|
|
158
192
|
```sh
|
|
@@ -160,7 +194,7 @@ gem install lyman
|
|
|
160
194
|
lyman new my-agent
|
|
161
195
|
```
|
|
162
196
|
|
|
163
|
-
On deck: tool-call fan-out
|
|
197
|
+
On deck: tool-call fan-out.
|
|
164
198
|
|
|
165
199
|
## License
|
|
166
200
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Design note: the harness archetypes — one circuit, three shells
|
|
2
|
+
|
|
3
|
+
**Status:** accepted (shipped with the repl, daemon, and script harnesses)
|
|
4
|
+
**Builds on:** [circuit-pattern.md](circuit-pattern.md) (the model⇄tool loop)
|
|
5
|
+
and the shell concept in [../vision.md](../vision.md)
|
|
6
|
+
|
|
7
|
+
## The claim these examples exist to prove
|
|
8
|
+
|
|
9
|
+
The vision document makes a structural claim: lyman is **turn-shaped, not
|
|
10
|
+
chat-shaped**. A human REPL is one shell; an autonomous email triager is
|
|
11
|
+
another; *same architecture, different shells*. One example harness can't
|
|
12
|
+
demonstrate that — a single example reads as "the way," and a chat example
|
|
13
|
+
reads as "lyman is a chat framework."
|
|
14
|
+
|
|
15
|
+
So lyman ships three harnesses. They are not three random examples; they are
|
|
16
|
+
**archetypes** — the three answers to the two questions every agentic
|
|
17
|
+
workflow must settle before any code is written:
|
|
18
|
+
|
|
19
|
+
1. **Where do work items come from?** (a human at a prompt, an inbound event
|
|
20
|
+
stream, the launch itself)
|
|
21
|
+
2. **When does the process end?** (when the human leaves, never, when the
|
|
22
|
+
item is done)
|
|
23
|
+
|
|
24
|
+
Everything else — which model, which tools, what the items mean — is
|
|
25
|
+
configuration *within* an archetype. The archetype is the shell shape.
|
|
26
|
+
|
|
27
|
+
## The invariant: the circuit does not change
|
|
28
|
+
|
|
29
|
+
Recall the two-part anatomy from the circuit-pattern note:
|
|
30
|
+
|
|
31
|
+
- The **circuit** is the linear pipeline that runs one work item to
|
|
32
|
+
completion: source-from-queue → model call → finish-or-continue check →
|
|
33
|
+
tool execution → re-enqueue-if-unfinished → only-finished-items-escape.
|
|
34
|
+
N model⇄tool rounds happen inside a single `pipeline.shift`, driven by
|
|
35
|
+
shifty's own demand semantics.
|
|
36
|
+
- The **shell** is the enclosing scope: state (the conversation, the `rounds`
|
|
37
|
+
queue) plus a driving process. Deliberately boring.
|
|
38
|
+
|
|
39
|
+
Across all three shipped harnesses, **the circuit is the same pipeline**
|
|
40
|
+
(the repl adds display side-workers; the daemon adds a logging side-worker;
|
|
41
|
+
those are splices, not rewiring). What differs is entirely in the shell:
|
|
42
|
+
|
|
43
|
+
| | REPL (`harness/repl.rb`) | Daemon (`harness/daemon.rb`) | Script (`harness/script.rb`) |
|
|
44
|
+
|------------------------|--------------------------|------------------------------|------------------------------|
|
|
45
|
+
| **Work items from** | a human at a prompt | an inbound event stream (socket, queue, webhook) | launch arguments or stdin |
|
|
46
|
+
| **Driving process** | `loop { read, enqueue, shift }` | `loop { accept, enqueue, shift, answer }` | no loop: enqueue, shift once, halt |
|
|
47
|
+
| **Ends when** | the human leaves (blank line, ctrl-c) | never (until killed) | the one item is done |
|
|
48
|
+
| **Human in the loop** | yes — the human *is* the supplier | no (unless spliced in) | no |
|
|
49
|
+
| **Conversation state** | one conversation, accreting turn by turn | fresh conversation per event | one conversation, one turn |
|
|
50
|
+
| **Output** | streamed to the terminal as it happens | reply to the event's sender; log to stdout | final answer on stdout |
|
|
51
|
+
| **Launched by** | a person | an init system, `ruby harness/daemon.rb &` | cron, a Makefile, another script |
|
|
52
|
+
|
|
53
|
+
That table *is* the design: three columns of shell decisions, zero rows that
|
|
54
|
+
touch the circuit.
|
|
55
|
+
|
|
56
|
+
## The archetypes
|
|
57
|
+
|
|
58
|
+
### REPL — human-in-the-loop-as-driver
|
|
59
|
+
|
|
60
|
+
The classic formula. The shell reads a line, enqueues the turn, shifts, and
|
|
61
|
+
repeats until the human ends it. Because the same human sees every answer,
|
|
62
|
+
the conversation **accretes**: the shell keeps one conversation binding
|
|
63
|
+
across the whole session, extending it with `with_user_message` on the way
|
|
64
|
+
into the circuit and rebinding to the finished turn that comes back
|
|
65
|
+
(`Conversation` is an immutable value — see
|
|
66
|
+
[immutable-conversation.md](immutable-conversation.md)).
|
|
67
|
+
|
|
68
|
+
The repl is also where a display layer earns its keep — a human is watching,
|
|
69
|
+
so the harness streams tokens, previews `<think>` blocks, and shows tool
|
|
70
|
+
calls as they happen. All of that lives in `harness/repl/` (one widget per
|
|
71
|
+
file, each an owned artifact), and its dependencies (cli-ui, reline) are
|
|
72
|
+
confined there: delete the display layer and the circuit doesn't notice.
|
|
73
|
+
|
|
74
|
+
### Daemon — launch once, loop on events
|
|
75
|
+
|
|
76
|
+
The shell starts an event supplier and loops forever: accept an event,
|
|
77
|
+
enqueue it, shift, deliver the answer back. No human is in the loop; the
|
|
78
|
+
model runs on its own recognizance, bounded by the same `max_rounds` guard
|
|
79
|
+
as every other harness. (A human *approval gate* can be spliced in as a side
|
|
80
|
+
worker that blocks on whatever collaborator the shell provides — see
|
|
81
|
+
"intervention" in the circuit-pattern note — but the archetype's default is
|
|
82
|
+
autonomy.)
|
|
83
|
+
|
|
84
|
+
The shipped daemon listens on a TCP socket, one line per event, because
|
|
85
|
+
that's stdlib and demoable with `nc`. **The supplier is this archetype's
|
|
86
|
+
variable**: a webhook server, an AMQP consumer, an IMAP poller, a filesystem
|
|
87
|
+
watcher — all daemon harnesses, differing only in the few lines that turn
|
|
88
|
+
"something arrived" into an enqueued work item. The circuit never knows
|
|
89
|
+
where events come from; only the queue does.
|
|
90
|
+
|
|
91
|
+
Each event gets a **fresh conversation** — a daemon's work items are
|
|
92
|
+
independent, so context doesn't accrete across them unless you decide it
|
|
93
|
+
should (a memory is a splice, not a default).
|
|
94
|
+
|
|
95
|
+
### Script — one work item, then halt
|
|
96
|
+
|
|
97
|
+
The shell is launched by something out of scope — cron, CI, a person who
|
|
98
|
+
wants one answer — and the work item arrives *with the launch*: `ARGV` or
|
|
99
|
+
stdin. Enqueue, shift once, print the final answer, exit. The exit code and
|
|
100
|
+
stdout are the interface, because the caller is a program.
|
|
101
|
+
|
|
102
|
+
The script archetype is the starkest demonstration that **repetition lives
|
|
103
|
+
in the pipeline, not the shell**: there is no loop anywhere in the file, yet
|
|
104
|
+
the model⇄tool circuit still runs as many rounds as the task needs, all
|
|
105
|
+
inside the single `pipeline.shift`.
|
|
106
|
+
|
|
107
|
+
## Shared sharp edges, per archetype
|
|
108
|
+
|
|
109
|
+
The library's sharp edges (see CLAUDE.md) show up in each shell in a
|
|
110
|
+
characteristic way — worth naming, because each archetype tempts a different
|
|
111
|
+
mistake:
|
|
112
|
+
|
|
113
|
+
- **The nil footgun** (a source returning `nil` ends the stream forever).
|
|
114
|
+
All three shells obey the same rule: *enqueue before you shift*. The repl
|
|
115
|
+
and daemon are tempted to shift inside their loops before anything
|
|
116
|
+
arrived; the script's straight-line shape makes the ordering obvious —
|
|
117
|
+
which is why it's the best archetype to read first.
|
|
118
|
+
- **Runaway turns.** The repl has a human who would notice a model stuck in
|
|
119
|
+
tool-call loops; the daemon and script do not. The `runaway?` /
|
|
120
|
+
`max_rounds` guard on `Conversation` is load-bearing in exactly the
|
|
121
|
+
harnesses where nobody is watching.
|
|
122
|
+
- **Blocking vs. streaming.** The repl streams (`on_delta`) because a human
|
|
123
|
+
is waiting; the daemon and script use the blocking transport because their
|
|
124
|
+
consumers want the finished answer, not tokens. Same
|
|
125
|
+
`Workers.chat_completion` seam either way.
|
|
126
|
+
|
|
127
|
+
## Choosing an archetype
|
|
128
|
+
|
|
129
|
+
Ask the two questions from the top:
|
|
130
|
+
|
|
131
|
+
- A person supplies work items interactively → **repl**.
|
|
132
|
+
- Events arrive on their own schedule, forever → **daemon**.
|
|
133
|
+
- The work item is known at launch and the process should end → **script**.
|
|
134
|
+
|
|
135
|
+
Then change the parts the archetype marks as variable: the system prompt,
|
|
136
|
+
the tools, the supplier (daemon), the output channel (script). The circuit
|
|
137
|
+
only needs rewiring when the *workflow* changes shape (approval gates,
|
|
138
|
+
fan-out, multi-model routing) — and then it's the same stock parts,
|
|
139
|
+
re-plumbed, per the circuit-pattern note.
|
|
140
|
+
|
|
141
|
+
## Deployment notes
|
|
142
|
+
|
|
143
|
+
- `lyman new` plants the **repl** — the archetype you can talk to sixty
|
|
144
|
+
seconds after scaffolding. The daemon and script are opt-in artifacts
|
|
145
|
+
(`lyman add daemon_harness`, `lyman add script_harness`): a narrow,
|
|
146
|
+
purpose-built agent wants one shell shape, not three files two of which
|
|
147
|
+
are dead weight.
|
|
148
|
+
- All three are **owned from day one** — wiring scripts, never targets of
|
|
149
|
+
`lyman update`. Improvements to archetypes ship as new examples to
|
|
150
|
+
`add`/`diff` against, never as merges into your harness.
|
|
151
|
+
- The daemon and script deliberately have **no display-layer dependencies**;
|
|
152
|
+
they are stdlib + the planted `lib/lyman` modules. Only the repl pulls in
|
|
153
|
+
cli-ui and reline, and only inside `harness/repl/`.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Immutable Conversation: adapting to shifty 0.6 frozen handoffs
|
|
2
|
+
|
|
3
|
+
Shifty 0.6.0 changed what crosses a worker boundary. Under the new default
|
|
4
|
+
handoff policy (`:frozen`), every value a worker receives is deeply frozen
|
|
5
|
+
via `Ractor.make_shareable` at intake; a task that mutates its input raises
|
|
6
|
+
`Shifty::PolicyViolation` at the offending worker. Per-worker escape hatches
|
|
7
|
+
exist (`policy: :isolated` for a private scratch copy, `policy: :shared` for
|
|
8
|
+
raw pre-0.6 references), and a global opt-out reproduces 0.5 behavior.
|
|
9
|
+
|
|
10
|
+
## The decision
|
|
11
|
+
|
|
12
|
+
`Conversation` became an immutable value, and no worker declares a policy.
|
|
13
|
+
This is shifty's own preferred migration path (rewrite non-destructively),
|
|
14
|
+
and it wins on lyman's design razor — whatever lets a user see and change
|
|
15
|
+
behavior faster:
|
|
16
|
+
|
|
17
|
+
- **Policy declarations are the second paradigm.** A pipeline where some
|
|
18
|
+
workers carry `policy: :shared` and some don't makes handoff semantics a
|
|
19
|
+
per-stage thing a reader must track. With an immutable item, every handoff
|
|
20
|
+
means the same thing everywhere.
|
|
21
|
+
- **Silent aliasing was the circuit's real risk.** The circuit re-enqueues
|
|
22
|
+
the item back into shell scope; a mutable conversation is shared between
|
|
23
|
+
the queue, the shell binding, and whatever a spliced-in worker holds onto.
|
|
24
|
+
Frozen handoffs turn that class of bug into an immediate, named error.
|
|
25
|
+
- **`:shared` restores exactly the protection-free semantics 0.6 exists to
|
|
26
|
+
end** — the escape hatch stays reserved for genuinely uncopyable values
|
|
27
|
+
(IO handles, procs), which the conversation is not.
|
|
28
|
+
|
|
29
|
+
## The shape
|
|
30
|
+
|
|
31
|
+
`Conversation < Data.define(...)` — Ruby's stdlib immutable value, the
|
|
32
|
+
idiom shifty's own docs recommend. `Data#with` allocates one new outer
|
|
33
|
+
object and structurally shares unchanged members between old and new
|
|
34
|
+
values; that sharing is safe precisely because handed-off values are
|
|
35
|
+
frozen. The two mechanisms are a matched set, and the per-handoff freeze
|
|
36
|
+
traversal only visits structure created since the last handoff, so the
|
|
37
|
+
combination stays cheap.
|
|
38
|
+
|
|
39
|
+
Consequences, visible in the code:
|
|
40
|
+
|
|
41
|
+
- Mutators became `with_*` methods returning a new conversation:
|
|
42
|
+
`with_user_message`, `with_assistant_message`, `with_tool_result`, and
|
|
43
|
+
`finish` (no bang — nothing mutates). `Data#with` remains public for
|
|
44
|
+
splicing in custom state.
|
|
45
|
+
- The round counter moved into `with_assistant_message`: a round *is* one
|
|
46
|
+
model reply, so counting it there means a swapped-in transport cannot
|
|
47
|
+
forget the runaway guard.
|
|
48
|
+
- Workers hand off what they build: `chat_completion` and `tool_execution`
|
|
49
|
+
return `conversation.with_*(...)` results instead of returning a mutated
|
|
50
|
+
input. Their internal message assembly (streaming deltas accumulating
|
|
51
|
+
into a hash) is untouched — closure and local state stay freely mutable;
|
|
52
|
+
the freeze happens at handoff. "Mutable within, immutable between."
|
|
53
|
+
- The shell rebinds instead of mutating: `harness/chat.rb` enqueues
|
|
54
|
+
`conversation.with_user_message(input)` and then does
|
|
55
|
+
`conversation = pipeline.shift`. Turn state flowing back to the shell is
|
|
56
|
+
now an explicit assignment sitting in the wiring script — state lives in
|
|
57
|
+
the shell's scope, visibly.
|
|
58
|
+
|
|
59
|
+
## What to watch when adding workers
|
|
60
|
+
|
|
61
|
+
- Never mutate the item; build a new one (`with_*`, `Data#with`, `merge`,
|
|
62
|
+
`+`). A `PolicyViolation` names the worker and object if you slip.
|
|
63
|
+
- A builder that keeps a reference to what it handed off will find it
|
|
64
|
+
frozen — hand off snapshots (`dup`, `join`, `with(...)`), keep the live
|
|
65
|
+
accumulator private.
|
|
66
|
+
- Unshareable values (IO, procs, lazy enumerators) cannot cross a default
|
|
67
|
+
boundary; if a worker must pass one, declare `policy: :shared` on that
|
|
68
|
+
worker and say why in a comment.
|
|
69
|
+
- `Shifty::Testing.mutates_input?(worker, input)` (opt-in
|
|
70
|
+
`require "shifty/testing"`) is the mutation detector — use it when
|
|
71
|
+
vetting a worker whose task you don't fully control.
|
data/harness/daemon.rb
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
#
|
|
3
|
+
# The daemon archetype: launch once, then loop on an inbound stream of
|
|
4
|
+
# events, indefinitely. No human in the loop — each arriving event is a
|
|
5
|
+
# work item, processed to completion and answered. One of three archetype
|
|
6
|
+
# shells — repl, daemon, script — around the same circuit; see
|
|
7
|
+
# docs/design/harness-archetypes.md.
|
|
8
|
+
#
|
|
9
|
+
# The shell: state + a process. The process is `loop { accept an event,
|
|
10
|
+
# enqueue, shift, answer }` — the same enqueue-then-shift rhythm as the
|
|
11
|
+
# repl, with a socket where the human used to be.
|
|
12
|
+
#
|
|
13
|
+
# The event supplier here is a line-per-event TCP listener, chosen because
|
|
14
|
+
# it's stdlib and legible — try it with:
|
|
15
|
+
#
|
|
16
|
+
# echo "what time is it?" | nc localhost 1216
|
|
17
|
+
#
|
|
18
|
+
# The supplier is this archetype's variable: swap the TCPServer for a
|
|
19
|
+
# webhook server, a queue consumer, an IMAP poller — the circuit below
|
|
20
|
+
# doesn't change, because the circuit never knows where events come from.
|
|
21
|
+
# Only the source_worker's queue does.
|
|
22
|
+
|
|
23
|
+
# require_relative, not the load path: these are files planted beside this
|
|
24
|
+
# script, not the lyman gem.
|
|
25
|
+
require_relative "../lib/lyman"
|
|
26
|
+
require "socket"
|
|
27
|
+
|
|
28
|
+
# This is a top-level wiring script by design; mixing the DSL into main
|
|
29
|
+
# is the point, not an accident.
|
|
30
|
+
include Shifty::DSL # standard:disable Style/MixinUsage
|
|
31
|
+
|
|
32
|
+
$stdout.sync = true
|
|
33
|
+
|
|
34
|
+
BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
|
|
35
|
+
MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
|
|
36
|
+
PORT = Integer(ENV.fetch("LYMAN_PORT", "1216")) # Lyman-alpha: 1216 Å
|
|
37
|
+
|
|
38
|
+
# ── Tools: schema and handler side by side, guts on the outside ────────────
|
|
39
|
+
TOOLS = {
|
|
40
|
+
"current_time" => {
|
|
41
|
+
schema: {
|
|
42
|
+
"type" => "function",
|
|
43
|
+
"function" => {
|
|
44
|
+
"name" => "current_time",
|
|
45
|
+
"description" => "Returns the current local date and time",
|
|
46
|
+
"parameters" => {"type" => "object", "properties" => {}, "required" => []}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
schemas = TOOLS.values.map { |tool| tool[:schema] }
|
|
54
|
+
handlers = TOOLS.transform_values { |tool| tool[:handler] }
|
|
55
|
+
|
|
56
|
+
# ── Shell state ─────────────────────────────────────────────────────────────
|
|
57
|
+
rounds = [] # the circuit's queue — visible right here, not smuggled
|
|
58
|
+
|
|
59
|
+
# ── The circuit ─────────────────────────────────────────────────────────────
|
|
60
|
+
# Identical to the script archetype's circuit, plus one logging side_worker —
|
|
61
|
+
# with no human watching, observability is spliced in, not bolted on.
|
|
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
|
+
side_worker { |c|
|
|
69
|
+
c.pending_tool_calls.each do |call|
|
|
70
|
+
puts " ⚙ #{call.dig("function", "name")} #{call.dig("function", "arguments")}"
|
|
71
|
+
end
|
|
72
|
+
} |
|
|
73
|
+
Lyman::Workers.tool_execution(handlers) |
|
|
74
|
+
side_worker { |c| rounds << c unless c.finished? } |
|
|
75
|
+
filter_worker { |c| c.finished? }
|
|
76
|
+
|
|
77
|
+
# ── Shell process ───────────────────────────────────────────────────────────
|
|
78
|
+
server = TCPServer.new(PORT)
|
|
79
|
+
puts "lyman daemon ⇢ #{MODEL} @ #{BASE_URL}, listening on port #{PORT}"
|
|
80
|
+
|
|
81
|
+
loop do
|
|
82
|
+
client = server.accept
|
|
83
|
+
event = begin
|
|
84
|
+
client.gets&.strip
|
|
85
|
+
rescue IOError, SystemCallError
|
|
86
|
+
nil # a client that hung up mid-line is not our problem
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if event.nil? || event.empty?
|
|
90
|
+
client.close
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
puts "[#{Time.now.strftime("%H:%M:%S")}] event: #{event}"
|
|
95
|
+
|
|
96
|
+
# Each event is an independent work item, so it gets a fresh conversation —
|
|
97
|
+
# a daemon accretes no context across events unless you decide it should.
|
|
98
|
+
conversation = Lyman::Conversation.new(
|
|
99
|
+
system_prompt: "You are an event handler. Handle the incoming event and " \
|
|
100
|
+
"reply with the result only — plain text, no markdown, no commentary."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Enqueue before shifting — never pull the source while the queue is empty
|
|
104
|
+
# (the nil footgun: a nil from a source ends the stream permanently).
|
|
105
|
+
# Conversation is an immutable value (shifty 0.6 freezes every handoff):
|
|
106
|
+
# with_user_message returns a new conversation, and the finished turn
|
|
107
|
+
# comes back from the pipeline.
|
|
108
|
+
rounds << conversation.with_user_message(event)
|
|
109
|
+
result = pipeline.shift
|
|
110
|
+
|
|
111
|
+
reply = result.last_assistant_content.to_s
|
|
112
|
+
puts "[#{Time.now.strftime("%H:%M:%S")}] reply: #{reply}"
|
|
113
|
+
client.puts reply
|
|
114
|
+
client.close
|
|
115
|
+
rescue Interrupt
|
|
116
|
+
puts "\nshutting down"
|
|
117
|
+
server.close
|
|
118
|
+
break
|
|
119
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "cli/ui"
|
|
2
|
+
require_relative "think_filter"
|
|
3
|
+
require_relative "wait_spinner"
|
|
4
|
+
|
|
5
|
+
# Streams one round's content to the terminal: a spinner while waiting on
|
|
6
|
+
# the model, then the model label before the first visible text — so silent
|
|
7
|
+
# rounds (pure tool calls) don't leave an empty prompt behind.
|
|
8
|
+
class RoundPrinter
|
|
9
|
+
def initialize(label)
|
|
10
|
+
@label = label
|
|
11
|
+
@spinner = WaitSpinner.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start_round
|
|
15
|
+
@think = ThinkFilter.new
|
|
16
|
+
@printed = false
|
|
17
|
+
@spinner.start("waiting for #{@label}…")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def delta(text)
|
|
21
|
+
think, reply = @think.filter(text)
|
|
22
|
+
emit(think)
|
|
23
|
+
emit(reply)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def finish_round
|
|
27
|
+
@spinner.stop
|
|
28
|
+
think, reply = @think.flush
|
|
29
|
+
emit(think)
|
|
30
|
+
emit(reply)
|
|
31
|
+
puts if @printed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def emit(text)
|
|
37
|
+
return if text.empty?
|
|
38
|
+
@spinner.stop
|
|
39
|
+
print CLI::UI.fmt("\n{{magenta:#{@label}}}> ") unless @printed
|
|
40
|
+
@printed = true
|
|
41
|
+
print text
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require "cli/ui"
|
|
2
|
+
|
|
3
|
+
# Styling codes used across streamed fragments, where CLI::UI.fmt can't help
|
|
4
|
+
# (fmt resets at the end of each call; a think preview arrives in pieces).
|
|
5
|
+
# Empty when piped, matching cli-ui's own no-tty behavior.
|
|
6
|
+
TTY = $stdout.tty?
|
|
7
|
+
GRAY = TTY ? CLI::UI.resolve_color(:gray).code : ""
|
|
8
|
+
DIM = TTY ? "\e[2;3m" : "" # faint italic, for think previews
|
|
9
|
+
RESET = TTY ? CLI::UI::Color::RESET.code : ""
|
|
10
|
+
|
|
11
|
+
# Paint without CLI::UI.fmt so text we don't control (tool arguments and
|
|
12
|
+
# results) can't be misread as {{markup}}.
|
|
13
|
+
def gray(text) = "#{GRAY}#{text}#{RESET}"
|