vsm 0.0.1 → 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 +4 -4
- data/.claude/settings.local.json +17 -0
- data/CLAUDE.md +134 -0
- data/README.md +531 -17
- data/examples/01_echo_tool.rb +70 -0
- data/examples/02_openai_streaming.rb +73 -0
- data/examples/02b_anthropic_streaming.rb +61 -0
- data/examples/02c_gemini_streaming.rb +60 -0
- data/examples/03_openai_tools.rb +106 -0
- data/examples/03b_anthropic_tools.rb +96 -0
- data/examples/03c_gemini_tools.rb +95 -0
- data/lib/vsm/async_channel.rb +21 -0
- data/lib/vsm/capsule.rb +44 -0
- data/lib/vsm/drivers/anthropic/async_driver.rb +210 -0
- data/lib/vsm/drivers/family.rb +16 -0
- data/lib/vsm/drivers/gemini/async_driver.rb +149 -0
- data/lib/vsm/drivers/openai/async_driver.rb +202 -0
- data/lib/vsm/dsl.rb +50 -0
- data/lib/vsm/executors/fiber_executor.rb +10 -0
- data/lib/vsm/executors/thread_executor.rb +19 -0
- data/lib/vsm/homeostat.rb +19 -0
- data/lib/vsm/lens/event_hub.rb +73 -0
- data/lib/vsm/lens/server.rb +188 -0
- data/lib/vsm/lens/stats.rb +58 -0
- data/lib/vsm/lens/tui.rb +88 -0
- data/lib/vsm/lens.rb +79 -0
- data/lib/vsm/message.rb +6 -0
- data/lib/vsm/observability/ledger.rb +25 -0
- data/lib/vsm/port.rb +11 -0
- data/lib/vsm/roles/coordination.rb +49 -0
- data/lib/vsm/roles/governance.rb +9 -0
- data/lib/vsm/roles/identity.rb +11 -0
- data/lib/vsm/roles/intelligence.rb +168 -0
- data/lib/vsm/roles/operations.rb +33 -0
- data/lib/vsm/runtime.rb +18 -0
- data/lib/vsm/tool/acts_as_tool.rb +20 -0
- data/lib/vsm/tool/capsule.rb +12 -0
- data/lib/vsm/tool/descriptor.rb +16 -0
- data/lib/vsm/version.rb +1 -1
- data/lib/vsm.rb +33 -0
- data/llms.txt +322 -0
- metadata +67 -25
data/README.md
CHANGED
@@ -1,39 +1,553 @@
|
|
1
|
-
#
|
1
|
+
# VSM — Viable Systems for Ruby Agents
|
2
2
|
|
3
|
-
|
3
|
+
[](https://github.com/discoveryworks/readme-dot-lint)
|
4
4
|
|
5
|
-
|
5
|
+
VSM is a tiny, idiomatic Ruby runtime for building agentic systems with a clear spine: Operations, Coordination, Intelligence, Governance, and Identity.
|
6
6
|
|
7
|
-
|
7
|
+
🌸 Why use VSM?
|
8
|
+
=============================
|
8
9
|
|
9
|
-
|
10
|
+
Building agentic systems often leads to tangled callback spaghetti and unclear responsibilities. As you add tools, LLM providers, and coordination logic, the complexity explodes. You end up with:
|
10
11
|
|
11
|
-
|
12
|
+
- Callbacks nested in callbacks with no clear flow
|
13
|
+
- Tool execution mixed with business logic
|
14
|
+
- No clear separation between "what the agent does" vs "how it decides" vs "what rules it follows"
|
15
|
+
- Difficulty testing individual components
|
16
|
+
- Lock-in to specific LLM providers or frameworks
|
17
|
+
|
18
|
+
VSM solves this by providing a composable, testable architecture with **named responsibilities** (POODR/SOLID style). You get clear separation of concerns from day one, and can start with a single capsule and grow to a swarm—without changing your interface or core loop.
|
19
|
+
|
20
|
+
The Viable System Model gives you a proven organizational pattern: every autonomous system needs Operations (doing), Coordination (scheduling), Intelligence (deciding), Governance (rules), and Identity (purpose). VSM makes this concrete in Ruby.
|
21
|
+
|
22
|
+
🌸🌸 Who benefits from VSM?
|
23
|
+
=============================
|
24
|
+
|
25
|
+
**Ruby developers building AI agents** who want clean architecture over framework magic. If you've read Sandi Metz's POODR, appreciate small objects with single responsibilities, and want your agent code to be as clean as your Rails models, VSM is for you.
|
26
|
+
|
27
|
+
**Teams scaling from prototype to production** who need to start simple (one tool, one LLM call) but know they'll need multiple tools, streaming, confirmations, and policy enforcement later. VSM's recursive capsule design means your "hello world" agent uses the same architecture as your production swarm.
|
28
|
+
|
29
|
+
**Developers who want provider independence**. VSM doesn't lock you into OpenAI, Anthropic, or any specific provider. Your Intelligence component decides how to plan—whether that's calling an LLM, following a state machine, or using your own logic.
|
30
|
+
|
31
|
+
🌸🌸🌸 What exactly is VSM?
|
32
|
+
=============================
|
33
|
+
|
34
|
+
VSM is a Ruby gem that provides:
|
35
|
+
|
36
|
+
1. **Five named systems** that every agent needs:
|
37
|
+
- **Operations** — do the work (tools/skills)
|
38
|
+
- **Coordination** — schedule, order, and arbitrate conversations (the "floor")
|
39
|
+
- **Intelligence** — plan/decide (e.g., call an LLM driver, or your own logic)
|
40
|
+
- **Governance** — enforce policy, safety, and budgets
|
41
|
+
- **Identity** — define purpose and invariants
|
42
|
+
|
43
|
+
2. **Capsules** — recursive building blocks. Every capsule has the five systems above plus a message bus. Capsules can contain child capsules, and "tools" are just capsules that opt-in to a tool interface.
|
44
|
+
|
45
|
+
3. **Async-first architecture** — powered by the `async` gem, VSM runs streaming, I/O, and multiple tool calls concurrently without blocking.
|
46
|
+
|
47
|
+
4. **Clean interfaces** — Ports translate external events (CLI, HTTP, MCP) into messages. Tools expose JSON Schema descriptors that work with any LLM provider.
|
48
|
+
|
49
|
+
5. **Built-in observability** — append-only JSONL ledger of all events, ready to feed into a monitoring UI.
|
50
|
+
|
51
|
+
🌸🌸🌸🌸 How do I use VSM?
|
52
|
+
=============================
|
53
|
+
|
54
|
+
## Install
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
# Gemfile
|
58
|
+
gem "vsm", "~> 0.0.1"
|
59
|
+
```
|
12
60
|
|
13
61
|
```bash
|
14
|
-
bundle
|
62
|
+
bundle install
|
15
63
|
```
|
16
64
|
|
17
|
-
|
65
|
+
Ruby 3.2+ recommended.
|
66
|
+
|
67
|
+
## Quick Example
|
68
|
+
|
69
|
+
Here's a minimal agent with one tool:
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
require "securerandom"
|
73
|
+
require "vsm"
|
74
|
+
|
75
|
+
# 1) Define a tool as a capsule
|
76
|
+
class EchoTool < VSM::ToolCapsule
|
77
|
+
tool_name "echo"
|
78
|
+
tool_description "Echoes a message"
|
79
|
+
tool_schema({
|
80
|
+
type: "object",
|
81
|
+
properties: { text: { type: "string" } },
|
82
|
+
required: ["text"]
|
83
|
+
})
|
84
|
+
|
85
|
+
def run(args)
|
86
|
+
"you said: #{args["text"]}"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# 2) Define your Intelligence (decides what to do)
|
91
|
+
class DemoIntelligence < VSM::Intelligence
|
92
|
+
def handle(message, bus:, **)
|
93
|
+
return false unless message.kind == :user
|
94
|
+
|
95
|
+
if message.payload =~ /\Aecho:\s*(.+)\z/
|
96
|
+
# User said "echo: something" - call the tool
|
97
|
+
bus.emit VSM::Message.new(
|
98
|
+
kind: :tool_call,
|
99
|
+
payload: { tool: "echo", args: { "text" => $1 } },
|
100
|
+
corr_id: SecureRandom.uuid,
|
101
|
+
meta: message.meta
|
102
|
+
)
|
103
|
+
else
|
104
|
+
# Just respond
|
105
|
+
bus.emit VSM::Message.new(
|
106
|
+
kind: :assistant,
|
107
|
+
payload: "Try: echo: hello",
|
108
|
+
meta: message.meta
|
109
|
+
)
|
110
|
+
end
|
111
|
+
true
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# 3) Build your agent using the DSL
|
116
|
+
capsule = VSM::DSL.define(:demo) do
|
117
|
+
identity klass: VSM::Identity, args: { identity: "demo", invariants: [] }
|
118
|
+
governance klass: VSM::Governance
|
119
|
+
coordination klass: VSM::Coordination
|
120
|
+
intelligence klass: DemoIntelligence
|
121
|
+
operations do
|
122
|
+
capsule :echo, klass: EchoTool
|
123
|
+
end
|
124
|
+
end
|
18
125
|
|
126
|
+
# 4) Add a simple CLI interface
|
127
|
+
class StdinPort < VSM::Port
|
128
|
+
def loop
|
129
|
+
session = SecureRandom.uuid
|
130
|
+
print "You: "
|
131
|
+
while (line = $stdin.gets&.chomp)
|
132
|
+
@capsule.bus.emit VSM::Message.new(
|
133
|
+
kind: :user,
|
134
|
+
payload: line,
|
135
|
+
meta: { session_id: session }
|
136
|
+
)
|
137
|
+
@capsule.roles[:coordination].wait_for_turn_end(session)
|
138
|
+
print "You: "
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def render_out(msg)
|
143
|
+
case msg.kind
|
144
|
+
when :assistant
|
145
|
+
puts "\nBot: #{msg.payload}"
|
146
|
+
when :tool_result
|
147
|
+
puts "\nTool> #{msg.payload}"
|
148
|
+
@capsule.bus.emit VSM::Message.new(
|
149
|
+
kind: :assistant,
|
150
|
+
payload: "(done)",
|
151
|
+
meta: msg.meta
|
152
|
+
)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# 5) Start the runtime
|
158
|
+
VSM::Runtime.start(capsule, ports: [StdinPort.new(capsule:)])
|
159
|
+
```
|
160
|
+
|
161
|
+
Run it:
|
19
162
|
```bash
|
20
|
-
|
163
|
+
ruby quickstart.rb
|
164
|
+
# You: echo: hello
|
165
|
+
# Tool> you said: hello
|
21
166
|
```
|
22
167
|
|
23
|
-
##
|
168
|
+
## Building a Real Agent
|
24
169
|
|
25
|
-
|
170
|
+
For a real agent with LLM integration:
|
26
171
|
|
27
|
-
|
172
|
+
```ruby
|
173
|
+
capsule = VSM::DSL.define(:my_agent) do
|
174
|
+
identity klass: VSM::Identity,
|
175
|
+
args: { identity: "my_agent", invariants: ["stay in workspace"] }
|
176
|
+
governance klass: VSM::Governance
|
177
|
+
coordination klass: VSM::Coordination
|
178
|
+
intelligence klass: MyLLMIntelligence # Your class that calls OpenAI/Anthropic/etc
|
179
|
+
monitoring klass: VSM::Monitoring # Optional: writes JSONL event log
|
180
|
+
|
181
|
+
operations do
|
182
|
+
capsule :list_files, klass: ListFilesTool
|
183
|
+
capsule :read_file, klass: ReadFileTool
|
184
|
+
capsule :write_file, klass: WriteFileTool
|
185
|
+
end
|
186
|
+
end
|
187
|
+
```
|
28
188
|
|
29
|
-
|
189
|
+
Your `MyLLMIntelligence` would:
|
190
|
+
1. Maintain conversation history
|
191
|
+
2. Call your LLM provider with available tools
|
192
|
+
3. Emit `:tool_call` messages when the LLM wants to use tools
|
193
|
+
4. Stream `:assistant_delta` tokens as they arrive
|
194
|
+
5. Emit final `:assistant` message when done
|
30
195
|
|
31
|
-
|
196
|
+
🌸🌸🌸🌸🌸 Extras
|
197
|
+
=============================
|
32
198
|
|
33
|
-
##
|
199
|
+
## Table of Contents
|
200
|
+
|
201
|
+
- [Features](#features)
|
202
|
+
- [Core Concepts](#core-concepts)
|
203
|
+
- [Tools as Capsules](#tools-as-capsules)
|
204
|
+
- [Async & Parallelism](#async--parallelism)
|
205
|
+
- [Ports (Interfaces)](#ports-interfaces)
|
206
|
+
- [Observability](#observability)
|
207
|
+
- [Writing an Intelligence](#writing-an-intelligence)
|
208
|
+
- [Testing](#testing)
|
209
|
+
- [Design Goals](#design-goals)
|
210
|
+
- [Roadmap](#roadmap)
|
211
|
+
- [FAQ](#faq)
|
212
|
+
- [API Overview](#api-overview)
|
213
|
+
- [License](#license)
|
214
|
+
- [Contributing](#contributing)
|
215
|
+
|
216
|
+
## Features
|
217
|
+
|
218
|
+
- **Named systems**: Operations, Coordination, Intelligence, Governance, Identity
|
219
|
+
- **Capsules**: recursive building blocks (a capsule can contain more capsules)
|
220
|
+
- **Async bus**: non‑blocking message channel with fan‑out subscribers
|
221
|
+
- **Structured concurrency**: streaming + multiple tool calls in parallel
|
222
|
+
- **Tools-as-capsules**: opt‑in tool interface + JSON Schema descriptors
|
223
|
+
- **Executors**: run tools in the current fiber or a thread pool (Ractor/Subprocess future)
|
224
|
+
- **Ports**: clean ingress/egress adapters for CLI/TUI/HTTP/MCP/etc.
|
225
|
+
- **Observability**: append‑only JSONL ledger you can feed into a UI later
|
226
|
+
- **POODR/SOLID**: small objects, high cohesion, low coupling
|
227
|
+
|
228
|
+
## Core Concepts
|
229
|
+
|
230
|
+
### Capsule
|
231
|
+
|
232
|
+
A container with five named systems and a message bus:
|
233
|
+
|
234
|
+
```
|
235
|
+
Capsule(:name)
|
236
|
+
├─ Identity (purpose & invariants)
|
237
|
+
├─ Governance (safety & budgets)
|
238
|
+
├─ Coordination (scheduling & "floor")
|
239
|
+
├─ Intelligence (planning/deciding)
|
240
|
+
├─ Operations (tools/skills)
|
241
|
+
└─ Monitoring (event ledger; optional)
|
242
|
+
```
|
243
|
+
|
244
|
+
Capsules can contain child capsules. Recursion means a "tool" can itself be a full agent if you want.
|
245
|
+
|
246
|
+
### Message
|
247
|
+
|
248
|
+
```ruby
|
249
|
+
VSM::Message.new(
|
250
|
+
kind: :user | :assistant | :assistant_delta | :tool_call | :tool_result | :plan | :policy | :audit | :confirm_request | :confirm_response,
|
251
|
+
payload: "any",
|
252
|
+
path: [:airb, :operations, :fs], # optional addressing
|
253
|
+
corr_id: "uuid", # correlate tool_call ↔ tool_result
|
254
|
+
meta: { session_id: "uuid", ... } # extra context
|
255
|
+
)
|
256
|
+
```
|
257
|
+
|
258
|
+
### AsyncChannel
|
259
|
+
|
260
|
+
A non‑blocking bus built on fibers (`async`). Emitting a message never blocks the emitter.
|
261
|
+
|
262
|
+
## Tools as Capsules
|
263
|
+
|
264
|
+
Any capsule can opt‑in to act as a "tool" by including `VSM::ActsAsTool` (already included in `VSM::ToolCapsule`).
|
265
|
+
|
266
|
+
```ruby
|
267
|
+
class ReadFile < VSM::ToolCapsule
|
268
|
+
tool_name "read_file"
|
269
|
+
tool_description "Read the contents of a UTF-8 text file at relative path."
|
270
|
+
tool_schema({
|
271
|
+
type: "object",
|
272
|
+
properties: { path: { type: "string" } },
|
273
|
+
required: ["path"]
|
274
|
+
})
|
275
|
+
|
276
|
+
def run(args)
|
277
|
+
path = governance_safe_path(args.fetch("path"))
|
278
|
+
File.read(path, mode: "r:UTF-8")
|
279
|
+
end
|
280
|
+
|
281
|
+
# Optional: choose how this tool executes
|
282
|
+
def execution_mode = :fiber # or :thread
|
283
|
+
|
284
|
+
private
|
285
|
+
|
286
|
+
def governance_safe_path(rel) = governance.instance_eval { # simple helper
|
287
|
+
full = File.expand_path(File.join(Dir.pwd, rel))
|
288
|
+
raise "outside workspace" unless full.start_with?(Dir.pwd)
|
289
|
+
full
|
290
|
+
}
|
291
|
+
end
|
292
|
+
```
|
293
|
+
|
294
|
+
VSM provides provider‑agnostic descriptors:
|
295
|
+
|
296
|
+
```ruby
|
297
|
+
tool = instance.tool_descriptor
|
298
|
+
tool.to_openai_tool # => {type:"function", function:{ name, description, parameters }}
|
299
|
+
tool.to_anthropic_tool # => {name, description, input_schema}
|
300
|
+
tool.to_gemini_tool # => {name, description, parameters}
|
301
|
+
```
|
302
|
+
|
303
|
+
**Why opt‑in?** Not every capsule should be callable as a tool. Opt‑in keeps coupling low. Later you can auto‑expose selected capsules as tools or via MCP.
|
304
|
+
|
305
|
+
## Async & Parallelism
|
306
|
+
|
307
|
+
VSM is async by default:
|
308
|
+
|
309
|
+
- The bus is fiber‑based and non‑blocking.
|
310
|
+
- The capsule loop drains messages without blocking emitters.
|
311
|
+
- Operations runs each tool call in its own task; tools can choose their execution mode:
|
312
|
+
- `:fiber` (default) — I/O‑bound, non‑blocking
|
313
|
+
- `:thread` — CPU‑ish work or blocking libraries
|
314
|
+
|
315
|
+
You can add Ractor/Subprocess executors later without changing the API.
|
316
|
+
|
317
|
+
## Ports (Interfaces)
|
318
|
+
|
319
|
+
A Port translates external events into messages and renders outgoing messages. Examples: CLI chat, TUI, HTTP, MCP stdio, editor plugin.
|
34
320
|
|
35
|
-
|
321
|
+
```ruby
|
322
|
+
class MyPort < VSM::Port
|
323
|
+
def loop
|
324
|
+
session = SecureRandom.uuid
|
325
|
+
while (line = $stdin.gets&.chomp)
|
326
|
+
@capsule.bus.emit VSM::Message.new(kind: :user, payload: line, meta: { session_id: session })
|
327
|
+
@capsule.roles[:coordination].wait_for_turn_end(session)
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
def render_out(msg)
|
332
|
+
case msg.kind
|
333
|
+
when :assistant_delta then $stdout.print(msg.payload)
|
334
|
+
when :assistant then puts "\nBot: #{msg.payload}"
|
335
|
+
when :confirm_request then confirm(msg)
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
def confirm(msg)
|
340
|
+
print "\nConfirm? #{msg.payload} [y/N] "
|
341
|
+
ok = ($stdin.gets || "").strip.downcase.start_with?("y")
|
342
|
+
@capsule.bus.emit VSM::Message.new(kind: :confirm_response, payload: { accepted: ok }, meta: msg.meta)
|
343
|
+
end
|
344
|
+
end
|
345
|
+
```
|
346
|
+
|
347
|
+
Start everything:
|
348
|
+
|
349
|
+
```ruby
|
350
|
+
VSM::Runtime.start(capsule, ports: [MyPort.new(capsule:)])
|
351
|
+
```
|
352
|
+
|
353
|
+
## Observability
|
354
|
+
|
355
|
+
VSM ships a tiny Monitoring role that writes an append‑only JSONL ledger:
|
356
|
+
|
357
|
+
```
|
358
|
+
.vsm.log.jsonl
|
359
|
+
{"ts":"2025-08-14T12:00:00Z","kind":"user","path":null,"corr_id":null,"meta":{"session_id":"..."}}
|
360
|
+
{"ts":"...","kind":"tool_call", ...}
|
361
|
+
{"ts":"...","kind":"tool_result", ...}
|
362
|
+
{"ts":"...","kind":"assistant", ...}
|
363
|
+
```
|
364
|
+
|
365
|
+
Use it to power a TUI/HTTP "Lens" later. Because everything flows over the bus, you get consistent events across nested capsules and sub‑agents.
|
366
|
+
|
367
|
+
## Writing an Intelligence
|
368
|
+
|
369
|
+
The Intelligence role is where you plan/decide. It might:
|
370
|
+
|
371
|
+
- forward a conversation to an LLM driver (OpenAI/Anthropic/Gemini),
|
372
|
+
- emit `:tool_call` messages when the model asks to use tools,
|
373
|
+
- stream `:assistant_delta` tokens and finish with `:assistant`.
|
374
|
+
|
375
|
+
Minimal example (no LLM, just logic):
|
376
|
+
|
377
|
+
```ruby
|
378
|
+
class MyIntelligence < VSM::Intelligence
|
379
|
+
def initialize
|
380
|
+
@history = Hash.new { |h,k| h[k] = [] }
|
381
|
+
end
|
382
|
+
|
383
|
+
def handle(message, bus:, **)
|
384
|
+
return false unless [:user, :tool_result].include?(message.kind)
|
385
|
+
sid = message.meta&.dig(:session_id)
|
386
|
+
@history[sid] << message
|
387
|
+
|
388
|
+
if message.kind == :user && message.payload =~ /read (.+)/
|
389
|
+
bus.emit VSM::Message.new(
|
390
|
+
kind: :tool_call,
|
391
|
+
payload: { tool: "read_file", args: { "path" => $1 } },
|
392
|
+
corr_id: SecureRandom.uuid,
|
393
|
+
meta: { session_id: sid }
|
394
|
+
)
|
395
|
+
else
|
396
|
+
bus.emit VSM::Message.new(kind: :assistant, payload: "ok", meta: { session_id: sid })
|
397
|
+
end
|
398
|
+
true
|
399
|
+
end
|
400
|
+
end
|
401
|
+
```
|
402
|
+
|
403
|
+
In your application, you can plug in provider drivers that stream and support native tool calling; Intelligence remains the same.
|
404
|
+
|
405
|
+
## Testing
|
406
|
+
|
407
|
+
VSM is designed for unit tests:
|
408
|
+
|
409
|
+
- **Capsules**: inject fake systems and assert dispatch.
|
410
|
+
- **Intelligence**: feed `:user` / `:tool_result` messages and assert emitted messages.
|
411
|
+
- **Tools**: call `#run` directly.
|
412
|
+
- **Ports**: treat like adapters; they're thin.
|
413
|
+
|
414
|
+
Quick smoke test:
|
415
|
+
|
416
|
+
```ruby
|
417
|
+
require "vsm"
|
418
|
+
|
419
|
+
RSpec.describe "tool dispatch" do
|
420
|
+
class T < VSM::ToolCapsule
|
421
|
+
tool_name "t"; tool_description "d"; tool_schema({ type: "object", properties: {}, required: [] })
|
422
|
+
def run(_args) = "ok"
|
423
|
+
end
|
424
|
+
|
425
|
+
it "routes tool_call to tool_result" do
|
426
|
+
cap = VSM::DSL.define(:test) do
|
427
|
+
identity klass: VSM::Identity, args: { identity: "t", invariants: [] }
|
428
|
+
governance klass: VSM::Governance
|
429
|
+
coordination klass: VSM::Coordination
|
430
|
+
intelligence klass: VSM::Intelligence
|
431
|
+
operations { capsule :t, klass: T }
|
432
|
+
end
|
433
|
+
|
434
|
+
q = Queue.new
|
435
|
+
cap.bus.subscribe { |m| q << m if m.kind == :tool_result }
|
436
|
+
cap.run
|
437
|
+
cap.bus.emit VSM::Message.new(kind: :tool_call, payload: { tool: "t", args: {} }, corr_id: "1")
|
438
|
+
expect(q.pop.payload).to eq("ok")
|
439
|
+
end
|
440
|
+
end
|
441
|
+
```
|
442
|
+
|
443
|
+
## Design Goals
|
444
|
+
|
445
|
+
- **Ergonomic Ruby** (small objects, clear names, blocks/DSL where it helps)
|
446
|
+
- **High cohesion, low coupling** (roles are tiny; tools are self‑contained)
|
447
|
+
- **Recursion by default** (any capsule can contain more capsules)
|
448
|
+
- **Async from day one** (non‑blocking bus; concurrent tools)
|
449
|
+
- **Portability** (no hard dependency on a specific LLM vendor)
|
450
|
+
- **Observability built‑in** (event ledger everywhere)
|
451
|
+
|
452
|
+
## Roadmap
|
453
|
+
|
454
|
+
- [ ] **Executors**: Ractor & Subprocess for heavy/risky tools
|
455
|
+
- [ ] **Limiter**: per‑tool semaphores and budgets (tokens/time/IO) in Governance
|
456
|
+
- [ ] **Lens UI**: terminal/HTTP viewer for plans, tools, and audits
|
457
|
+
- [ ] **Drivers**: optional `vsm-openai`, `vsm-anthropic`, `vsm-gemini` add‑ons for native tool‑calling + streaming
|
458
|
+
- [ ] **MCP ports**: stdio server/client to expose/consume MCP tools
|
459
|
+
|
460
|
+
## FAQ
|
461
|
+
|
462
|
+
**Does every capsule have to be a tool?**
|
463
|
+
No. Opt‑in via `VSM::ActsAsTool`. Many capsules (planner, auditor, coordinator) shouldn't be callable as tools.
|
464
|
+
|
465
|
+
**Can I run multiple interfaces at once (chat + HTTP + MCP)?**
|
466
|
+
Yes. Start multiple ports; Coordination arbitrates the "floor" per session.
|
467
|
+
|
468
|
+
**How do I isolate risky or CPU‑heavy tools?**
|
469
|
+
Set `execution_mode` to `:thread` today. Ractor/Subprocess executors are planned and will use the same API.
|
470
|
+
|
471
|
+
**What about streaming tokens?**
|
472
|
+
Handled by your Intelligence implementation (e.g., your LLM driver). Emit `:assistant_delta` messages as tokens arrive; finish with a single `:assistant`.
|
473
|
+
|
474
|
+
**Is VSM tied to any specific LLM?**
|
475
|
+
No. Write a driver that conforms to your Intelligence's expectations (usually "yield deltas" + "yield tool_calls"). Keep the provider in your app gem.
|
476
|
+
|
477
|
+
## API Overview
|
478
|
+
|
479
|
+
```ruby
|
480
|
+
module VSM
|
481
|
+
# Messages
|
482
|
+
Message(kind:, payload:, path: nil, corr_id: nil, meta: {})
|
483
|
+
|
484
|
+
# Bus
|
485
|
+
class AsyncChannel
|
486
|
+
def emit(message); end
|
487
|
+
def pop; end
|
488
|
+
def subscribe(&block); end
|
489
|
+
attr_reader :context
|
490
|
+
end
|
491
|
+
|
492
|
+
# Roles (named systems)
|
493
|
+
class Operations; end # routes tool_call -> children
|
494
|
+
class Coordination; end # scheduling, floor, turn-end
|
495
|
+
class Intelligence; end # your planning/LLM driver
|
496
|
+
class Governance; end # policy/safety/budgets
|
497
|
+
class Identity; end # invariants & escalation
|
498
|
+
class Monitoring; end # JSONL ledger (observe)
|
499
|
+
|
500
|
+
# Capsules & DSL
|
501
|
+
class Capsule; end
|
502
|
+
module DSL
|
503
|
+
def self.define(:name, &block) -> Capsule
|
504
|
+
end
|
505
|
+
|
506
|
+
# Tools
|
507
|
+
module Tool
|
508
|
+
Descriptor#to_openai_tool / #to_anthropic_tool / #to_gemini_tool
|
509
|
+
end
|
510
|
+
module ActsAsTool; end
|
511
|
+
class ToolCapsule
|
512
|
+
# include ActsAsTool
|
513
|
+
# def run(args) ...
|
514
|
+
# def execution_mode = :fiber | :thread
|
515
|
+
end
|
516
|
+
|
517
|
+
# Ports & runtime
|
518
|
+
class Port
|
519
|
+
def initialize(capsule:); end
|
520
|
+
def loop; end # optional
|
521
|
+
def render_out(msg); end
|
522
|
+
def egress_subscribe; end
|
523
|
+
end
|
524
|
+
|
525
|
+
module Runtime
|
526
|
+
def self.start(capsule, ports: [])
|
527
|
+
end
|
528
|
+
end
|
529
|
+
```
|
36
530
|
|
37
531
|
## License
|
38
532
|
|
39
|
-
|
533
|
+
MIT. See LICENSE.txt.
|
534
|
+
|
535
|
+
## Contributing
|
536
|
+
|
537
|
+
Issues and PRs are welcome! Please include:
|
538
|
+
|
539
|
+
- A failing spec (RSpec) for bug reports
|
540
|
+
- Minimal API additions
|
541
|
+
- Clear commit messages
|
542
|
+
|
543
|
+
Run tests with:
|
544
|
+
|
545
|
+
```bash
|
546
|
+
bundle exec rspec
|
547
|
+
```
|
548
|
+
|
549
|
+
Lint with:
|
550
|
+
|
551
|
+
```bash
|
552
|
+
bundle exec rubocop
|
553
|
+
```
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
3
|
+
require "vsm"
|
4
|
+
|
5
|
+
class EchoTool < VSM::ToolCapsule
|
6
|
+
tool_name "echo"
|
7
|
+
tool_description "Echoes a message"
|
8
|
+
tool_schema({ type: "object", properties: { text: { type: "string" } }, required: ["text"] })
|
9
|
+
|
10
|
+
def run(args)
|
11
|
+
"you said: #{args["text"]}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Minimal “intelligence” that triggers a tool when user types "echo: ..."
|
16
|
+
class DemoIntelligence < VSM::Intelligence
|
17
|
+
def handle(message, bus:, **)
|
18
|
+
case message.kind
|
19
|
+
when :user
|
20
|
+
if message.payload =~ /\Aecho:\s*(.+)\z/
|
21
|
+
bus.emit VSM::Message.new(kind: :tool_call, payload: { tool: "echo", args: { "text" => $1 } }, corr_id: SecureRandom.uuid, meta: message.meta)
|
22
|
+
else
|
23
|
+
bus.emit VSM::Message.new(kind: :assistant, payload: "Try: echo: hello", meta: message.meta)
|
24
|
+
end
|
25
|
+
true
|
26
|
+
when :tool_result
|
27
|
+
# Complete the turn after tool execution
|
28
|
+
bus.emit VSM::Message.new(kind: :assistant, payload: "(done)", meta: message.meta)
|
29
|
+
true
|
30
|
+
else
|
31
|
+
false
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
cap = VSM::DSL.define(:demo) do
|
37
|
+
identity klass: VSM::Identity, args: { identity: "demo", invariants: [] }
|
38
|
+
governance klass: VSM::Governance
|
39
|
+
coordination klass: VSM::Coordination
|
40
|
+
intelligence klass: DemoIntelligence
|
41
|
+
monitoring klass: VSM::Monitoring
|
42
|
+
operations do
|
43
|
+
capsule :echo, klass: EchoTool
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Simple CLI port
|
48
|
+
class StdinPort < VSM::Port
|
49
|
+
def loop
|
50
|
+
sid = SecureRandom.uuid
|
51
|
+
print "You: "
|
52
|
+
while (line = $stdin.gets&.chomp)
|
53
|
+
@capsule.bus.emit VSM::Message.new(kind: :user, payload: line, meta: { session_id: sid })
|
54
|
+
@capsule.roles[:coordination].wait_for_turn_end(sid)
|
55
|
+
print "You: "
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def render_out(msg)
|
60
|
+
case msg.kind
|
61
|
+
when :assistant
|
62
|
+
puts "\nBot: #{msg.payload}"
|
63
|
+
when :tool_result
|
64
|
+
puts "\nTool> #{msg.payload}"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
VSM::Runtime.start(cap, ports: [StdinPort.new(capsule: cap)])
|
70
|
+
|