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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/settings.local.json +17 -0
  3. data/CLAUDE.md +134 -0
  4. data/README.md +531 -17
  5. data/examples/01_echo_tool.rb +70 -0
  6. data/examples/02_openai_streaming.rb +73 -0
  7. data/examples/02b_anthropic_streaming.rb +61 -0
  8. data/examples/02c_gemini_streaming.rb +60 -0
  9. data/examples/03_openai_tools.rb +106 -0
  10. data/examples/03b_anthropic_tools.rb +96 -0
  11. data/examples/03c_gemini_tools.rb +95 -0
  12. data/lib/vsm/async_channel.rb +21 -0
  13. data/lib/vsm/capsule.rb +44 -0
  14. data/lib/vsm/drivers/anthropic/async_driver.rb +210 -0
  15. data/lib/vsm/drivers/family.rb +16 -0
  16. data/lib/vsm/drivers/gemini/async_driver.rb +149 -0
  17. data/lib/vsm/drivers/openai/async_driver.rb +202 -0
  18. data/lib/vsm/dsl.rb +50 -0
  19. data/lib/vsm/executors/fiber_executor.rb +10 -0
  20. data/lib/vsm/executors/thread_executor.rb +19 -0
  21. data/lib/vsm/homeostat.rb +19 -0
  22. data/lib/vsm/lens/event_hub.rb +73 -0
  23. data/lib/vsm/lens/server.rb +188 -0
  24. data/lib/vsm/lens/stats.rb +58 -0
  25. data/lib/vsm/lens/tui.rb +88 -0
  26. data/lib/vsm/lens.rb +79 -0
  27. data/lib/vsm/message.rb +6 -0
  28. data/lib/vsm/observability/ledger.rb +25 -0
  29. data/lib/vsm/port.rb +11 -0
  30. data/lib/vsm/roles/coordination.rb +49 -0
  31. data/lib/vsm/roles/governance.rb +9 -0
  32. data/lib/vsm/roles/identity.rb +11 -0
  33. data/lib/vsm/roles/intelligence.rb +168 -0
  34. data/lib/vsm/roles/operations.rb +33 -0
  35. data/lib/vsm/runtime.rb +18 -0
  36. data/lib/vsm/tool/acts_as_tool.rb +20 -0
  37. data/lib/vsm/tool/capsule.rb +12 -0
  38. data/lib/vsm/tool/descriptor.rb +16 -0
  39. data/lib/vsm/version.rb +1 -1
  40. data/lib/vsm.rb +33 -0
  41. data/llms.txt +322 -0
  42. metadata +67 -25
data/README.md CHANGED
@@ -1,39 +1,553 @@
1
- # Vsm
1
+ # VSM — Viable Systems for Ruby Agents
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ [![Conforms to README.lint](https://img.shields.io/badge/README.lint-conforming-brightgreen)](https://github.com/discoveryworks/readme-dot-lint)
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/vsm`. To experiment with that code, run `bin/console` for an interactive prompt.
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
- ## Installation
7
+ 🌸 Why use VSM?
8
+ =============================
8
9
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
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
- Install the gem and add to the application's Gemfile by executing:
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 add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
62
+ bundle install
15
63
  ```
16
64
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
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
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
163
+ ruby quickstart.rb
164
+ # You: echo: hello
165
+ # Tool> you said: hello
21
166
  ```
22
167
 
23
- ## Usage
168
+ ## Building a Real Agent
24
169
 
25
- TODO: Write usage instructions here
170
+ For a real agent with LLM integration:
26
171
 
27
- ## Development
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
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
196
+ 🌸🌸🌸🌸🌸 Extras
197
+ =============================
32
198
 
33
- ## Contributing
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
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/vsm.
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
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
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
+