vsm 0.0.1 → 0.2.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 (78) 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 +675 -17
  5. data/Rakefile +1 -5
  6. data/examples/01_echo_tool.rb +51 -0
  7. data/examples/02_openai_streaming.rb +73 -0
  8. data/examples/02b_anthropic_streaming.rb +58 -0
  9. data/examples/02c_gemini_streaming.rb +60 -0
  10. data/examples/03_openai_tools.rb +106 -0
  11. data/examples/03b_anthropic_tools.rb +93 -0
  12. data/examples/03c_gemini_tools.rb +95 -0
  13. data/examples/05_mcp_server_and_chattty.rb +63 -0
  14. data/examples/06_mcp_mount_reflection.rb +45 -0
  15. data/examples/07_connect_claude_mcp.rb +78 -0
  16. data/examples/08_custom_chattty.rb +63 -0
  17. data/examples/09_mcp_with_llm_calls.rb +49 -0
  18. data/examples/10_meta_read_only.rb +56 -0
  19. data/exe/vsm +17 -0
  20. data/lib/vsm/async_channel.rb +44 -0
  21. data/lib/vsm/capsule.rb +46 -0
  22. data/lib/vsm/cli.rb +78 -0
  23. data/lib/vsm/drivers/anthropic/async_driver.rb +210 -0
  24. data/lib/vsm/drivers/family.rb +16 -0
  25. data/lib/vsm/drivers/gemini/async_driver.rb +149 -0
  26. data/lib/vsm/drivers/openai/async_driver.rb +202 -0
  27. data/lib/vsm/dsl.rb +80 -0
  28. data/lib/vsm/dsl_mcp.rb +36 -0
  29. data/lib/vsm/executors/fiber_executor.rb +10 -0
  30. data/lib/vsm/executors/thread_executor.rb +19 -0
  31. data/lib/vsm/generator/new_project.rb +154 -0
  32. data/lib/vsm/generator/templates/Gemfile.erb +9 -0
  33. data/lib/vsm/generator/templates/README_md.erb +40 -0
  34. data/lib/vsm/generator/templates/Rakefile.erb +5 -0
  35. data/lib/vsm/generator/templates/bin_console.erb +11 -0
  36. data/lib/vsm/generator/templates/bin_setup.erb +7 -0
  37. data/lib/vsm/generator/templates/exe_name.erb +34 -0
  38. data/lib/vsm/generator/templates/gemspec.erb +24 -0
  39. data/lib/vsm/generator/templates/gitignore.erb +10 -0
  40. data/lib/vsm/generator/templates/lib_name_rb.erb +9 -0
  41. data/lib/vsm/generator/templates/lib_organism_rb.erb +44 -0
  42. data/lib/vsm/generator/templates/lib_ports_chat_tty_rb.erb +12 -0
  43. data/lib/vsm/generator/templates/lib_tools_read_file_rb.erb +32 -0
  44. data/lib/vsm/generator/templates/lib_version_rb.erb +6 -0
  45. data/lib/vsm/homeostat.rb +19 -0
  46. data/lib/vsm/lens/event_hub.rb +73 -0
  47. data/lib/vsm/lens/server.rb +188 -0
  48. data/lib/vsm/lens/stats.rb +58 -0
  49. data/lib/vsm/lens/tui.rb +88 -0
  50. data/lib/vsm/lens.rb +79 -0
  51. data/lib/vsm/mcp/client.rb +80 -0
  52. data/lib/vsm/mcp/jsonrpc.rb +92 -0
  53. data/lib/vsm/mcp/remote_tool_capsule.rb +35 -0
  54. data/lib/vsm/message.rb +6 -0
  55. data/lib/vsm/meta/snapshot_builder.rb +121 -0
  56. data/lib/vsm/meta/snapshot_cache.rb +25 -0
  57. data/lib/vsm/meta/support.rb +35 -0
  58. data/lib/vsm/meta/tools.rb +498 -0
  59. data/lib/vsm/meta.rb +59 -0
  60. data/lib/vsm/observability/ledger.rb +25 -0
  61. data/lib/vsm/port.rb +11 -0
  62. data/lib/vsm/ports/chat_tty.rb +112 -0
  63. data/lib/vsm/ports/mcp/server_stdio.rb +101 -0
  64. data/lib/vsm/roles/coordination.rb +49 -0
  65. data/lib/vsm/roles/governance.rb +9 -0
  66. data/lib/vsm/roles/identity.rb +11 -0
  67. data/lib/vsm/roles/intelligence.rb +172 -0
  68. data/lib/vsm/roles/operations.rb +33 -0
  69. data/lib/vsm/runtime.rb +18 -0
  70. data/lib/vsm/tool/acts_as_tool.rb +20 -0
  71. data/lib/vsm/tool/capsule.rb +12 -0
  72. data/lib/vsm/tool/descriptor.rb +16 -0
  73. data/lib/vsm/version.rb +1 -1
  74. data/lib/vsm.rb +43 -0
  75. data/llms.txt +322 -0
  76. data/mcp_update.md +162 -0
  77. metadata +93 -31
  78. data/.rubocop.yml +0 -8
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "async"
4
+
5
+ module VSM
6
+ module Ports
7
+ module MCP
8
+ # Exposes the capsule's tools as an MCP server over stdio (NDJSON JSON-RPC).
9
+ # Implemented methods: tools/list, tools/call.
10
+ class ServerStdio < VSM::Port
11
+ def initialize(capsule:)
12
+ super(capsule: capsule)
13
+ @waiters = {}
14
+ @waiters_mutex = Mutex.new
15
+ end
16
+
17
+ def egress_subscribe
18
+ # Single subscriber that resolves tool_result waiters by corr_id
19
+ @capsule.bus.subscribe do |m|
20
+ next unless m.kind == :tool_result
21
+ q = nil
22
+ @waiters_mutex.synchronize { q = @waiters.delete(m.corr_id.to_s) }
23
+ q&.enqueue(m)
24
+ end
25
+ super
26
+ end
27
+
28
+ def loop
29
+ $stdout.sync = true
30
+ while (line = $stdin.gets)
31
+ begin
32
+ req = JSON.parse(line)
33
+ rescue => e
34
+ write_err(nil, code: -32700, message: "Parse error: #{e.message}")
35
+ next
36
+ end
37
+
38
+ id = req["id"]
39
+ method = req["method"]
40
+ params = req["params"] || {}
41
+ case method
42
+ when "tools/list"
43
+ write_ok(id, { tools: list_tools })
44
+ when "tools/call"
45
+ name = params["name"].to_s
46
+ args = params["arguments"] || {}
47
+ res = call_local_tool(id, name, args)
48
+ write_ok(id, { content: [{ type: "text", text: res.to_s }] })
49
+ else
50
+ write_err(id, code: -32601, message: "Method not found: #{method}")
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def list_tools
58
+ ops = @capsule.bus.context[:operations_children] || {}
59
+ ops.values
60
+ .select { _1.respond_to?(:tool_descriptor) }
61
+ .map { to_mcp_descriptor(_1.tool_descriptor) }
62
+ end
63
+
64
+ def to_mcp_descriptor(desc)
65
+ {
66
+ "name" => desc.name,
67
+ "description" => desc.description,
68
+ "input_schema" => desc.schema
69
+ }
70
+ end
71
+
72
+ def call_local_tool(req_id, name, args)
73
+ corr = req_id.to_s
74
+ q = Async::Queue.new
75
+ @waiters_mutex.synchronize { @waiters[corr] = q }
76
+ @capsule.bus.emit VSM::Message.new(
77
+ kind: :tool_call,
78
+ payload: { tool: name, args: args },
79
+ corr_id: corr,
80
+ meta: { session_id: "mcp:stdio" },
81
+ path: [:mcp, :server, name]
82
+ )
83
+ msg = q.dequeue
84
+ msg.payload
85
+ ensure
86
+ @waiters_mutex.synchronize { @waiters.delete(corr) }
87
+ end
88
+
89
+ def write_ok(id, result)
90
+ puts JSON.dump({ jsonrpc: "2.0", id: id, result: result })
91
+ $stdout.flush
92
+ end
93
+
94
+ def write_err(id, code:, message:)
95
+ puts JSON.dump({ jsonrpc: "2.0", id: id, error: { code: code, message: message } })
96
+ $stdout.flush
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class Coordination
4
+ def initialize
5
+ @queue = []
6
+ @floor_by_session = nil
7
+ @turn_waiters = {} # session_id => Async::Queue
8
+ end
9
+
10
+ def observe(bus)
11
+ # Note: staging is handled by the capsule loop, not by subscription
12
+ # This method exists for consistency but doesn't auto-stage messages
13
+ end
14
+
15
+ def stage(message) = (@queue << message)
16
+
17
+ def drain(bus)
18
+ return if @queue.empty?
19
+ @queue.sort_by! { order(_1) }
20
+ @queue.shift(@queue.size).each do |msg|
21
+ yield msg
22
+ if msg.kind == :assistant && (sid = msg.meta&.dig(:session_id)) && @turn_waiters[sid]
23
+ @turn_waiters[sid].enqueue(:done)
24
+ end
25
+ end
26
+ end
27
+
28
+ def grant_floor!(session_id) = (@floor_by_session = session_id)
29
+
30
+ def wait_for_turn_end(session_id)
31
+ q = (@turn_waiters[session_id] ||= Async::Queue.new)
32
+ q.dequeue
33
+ end
34
+
35
+ def order(m)
36
+ base =
37
+ case m.kind
38
+ when :user then 0
39
+ when :tool_result then 1
40
+ when :plan then 2
41
+ when :assistant_delta then 3
42
+ when :assistant then 4
43
+ else 9
44
+ end
45
+ sid = m.meta&.dig(:session_id)
46
+ sid == @floor_by_session ? base - 1 : base
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class Governance
4
+ def observe(bus); end
5
+ def enforce(message)
6
+ yield message
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class Identity
4
+ def initialize(identity:, invariants: [])
5
+ @identity, @invariants = identity, invariants
6
+ end
7
+ def observe(bus); end
8
+ def handle(message, bus:, **) = false
9
+ def alert(message); end
10
+ end
11
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+ require "set"
3
+ require_relative "../drivers/family"
4
+
5
+ module VSM
6
+ # Orchestrates multi-turn LLM chat with native tool-calls:
7
+ # - Maintains neutral conversation history per session_id
8
+ # - Talks to a provider driver that yields :assistant_delta, :assistant_final, :tool_calls
9
+ # - Emits :tool_call to Operations, waits for ALL results, then continues
10
+ #
11
+ # App authors can subclass and only customize:
12
+ # - system_prompt(session_id) -> String
13
+ # - offer_tools?(session_id, descriptor) -> true/false (filter tools)
14
+ class Intelligence
15
+ attr_reader :driver
16
+
17
+ def initialize(driver: nil, system_prompt: nil)
18
+ @driver = driver
19
+ @system_prompt = system_prompt
20
+ @sessions = Hash.new { |h,k| h[k] = new_session_state }
21
+ end
22
+
23
+ def observe(bus); end
24
+
25
+ def handle(message, bus:, **)
26
+ # If no driver is configured, the base implementation is inert.
27
+ # Subclasses can override #handle to implement non-LLM behavior.
28
+ return false if @driver.nil?
29
+ case message.kind
30
+ when :user
31
+ sid = message.meta&.dig(:session_id)
32
+ st = state(sid)
33
+ st[:history] << { role: "user", content: message.payload.to_s }
34
+ invoke_model(sid, bus)
35
+ true
36
+
37
+ when :tool_result
38
+ sid = message.meta&.dig(:session_id)
39
+ st = state(sid)
40
+ # map id -> tool name if we learned it earlier (useful for Gemini)
41
+ name = st[:tool_id_to_name][message.corr_id]
42
+
43
+ # Debug logging
44
+ if ENV["VSM_DEBUG_STREAM"] == "1"
45
+ $stderr.puts "Intelligence: Received tool_result for #{name}(#{message.corr_id}): #{message.payload.to_s.slice(0, 100)}"
46
+ end
47
+
48
+ st[:history] << { role: "tool_result", tool_call_id: message.corr_id, name: name, content: message.payload.to_s }
49
+ st[:pending_tool_ids].delete(message.corr_id)
50
+ # Only continue once all tool results for this turn arrived:
51
+ if st[:pending_tool_ids].empty?
52
+ # Re-enter model for the same turn with tool results in history:
53
+ invoke_model(sid, bus)
54
+ end
55
+ true
56
+
57
+ else
58
+ false
59
+ end
60
+ end
61
+
62
+ # --- Extension points for apps ---
63
+
64
+ # Override to compute a dynamic prompt per session
65
+ def system_prompt(session_id)
66
+ @system_prompt
67
+ end
68
+
69
+ # Override to filter tools the model may use (by descriptor)
70
+ def offer_tools?(session_id, descriptor)
71
+ true
72
+ end
73
+
74
+ private
75
+
76
+ def new_session_state
77
+ {
78
+ history: [],
79
+ pending_tool_ids: Set.new,
80
+ tool_id_to_name: {},
81
+ inflight: false,
82
+ turn_seq: 0
83
+ }
84
+ end
85
+
86
+ def state(sid) = @sessions[sid]
87
+
88
+ def invoke_model(session_id, bus)
89
+ st = state(session_id)
90
+ if st[:inflight] || !st[:pending_tool_ids].empty?
91
+ if ENV["VSM_DEBUG_STREAM"] == "1"
92
+ $stderr.puts "Intelligence: skip invoke sid=#{session_id} inflight=#{st[:inflight]} pending=#{st[:pending_tool_ids].size}"
93
+ end
94
+ return
95
+ end
96
+ st[:inflight] = true
97
+ st[:turn_seq] += 1
98
+ current_turn_id = st[:turn_seq]
99
+
100
+ # Discover tools available from Operations children:
101
+ descriptors, name_index = tool_inventory(bus, session_id)
102
+
103
+ # Debug logging
104
+ if ENV["VSM_DEBUG_STREAM"] == "1"
105
+ $stderr.puts "Intelligence: invoke_model sid=#{session_id} inflight=#{st[:inflight]} pending=#{st[:pending_tool_ids].size} turn_seq=#{st[:turn_seq]}"
106
+ $stderr.puts "Intelligence: Calling driver with #{st[:history].size} history entries"
107
+ st[:history].each_with_index do |h, i|
108
+ $stderr.puts " [#{i}] #{h[:role]}: #{h[:role] == 'assistant_tool_calls' ? h[:tool_calls].map{|tc| "#{tc[:name]}(#{tc[:id]})"}.join(', ') : h[:content]&.slice(0, 100)}"
109
+ end
110
+ end
111
+
112
+ task = Async do
113
+ begin
114
+ @driver.run!(
115
+ conversation: st[:history],
116
+ tools: descriptors,
117
+ policy: { system_prompt: system_prompt(session_id) }
118
+ ) do |event, payload|
119
+ case event
120
+ when :assistant_delta
121
+ # optionally buffer based on stream_policy
122
+ bus.emit VSM::Message.new(kind: :assistant_delta, payload: payload, meta: { session_id: session_id, turn_id: current_turn_id })
123
+ when :assistant_final
124
+ unless payload.to_s.empty?
125
+ st[:history] << { role: "assistant", content: payload.to_s }
126
+ end
127
+ bus.emit VSM::Message.new(kind: :assistant, payload: payload, meta: { session_id: session_id, turn_id: current_turn_id })
128
+ when :tool_calls
129
+ st[:history] << { role: "assistant_tool_calls", tool_calls: payload }
130
+ st[:pending_tool_ids] = Set.new(payload.map { _1[:id] })
131
+ payload.each { |c| st[:tool_id_to_name][c[:id]] = c[:name] }
132
+ if ENV["VSM_DEBUG_STREAM"] == "1"
133
+ $stderr.puts "Intelligence: tool_calls count=#{payload.size}; pending now=#{st[:pending_tool_ids].size}"
134
+ end
135
+ # Allow next invocation (after tools complete) without waiting for driver ensure
136
+ st[:inflight] = false
137
+ payload.each do |call|
138
+ bus.emit VSM::Message.new(
139
+ kind: :tool_call,
140
+ payload: { tool: call[:name], args: call[:arguments] },
141
+ corr_id: call[:id],
142
+ meta: { session_id: session_id, tool: call[:name], turn_id: current_turn_id }
143
+ )
144
+ end
145
+ end
146
+ end
147
+ ensure
148
+ if ENV["VSM_DEBUG_STREAM"] == "1"
149
+ $stderr.puts "Intelligence: driver completed sid=#{session_id}; pending=#{st[:pending_tool_ids].size}; inflight->false"
150
+ end
151
+ st[:inflight] = false
152
+ end
153
+ end
154
+ st[:task] = task
155
+ end
156
+
157
+ # Return [descriptors:Array<VSM::Tool::Descriptor>, index Hash{name=>capsule}]
158
+ def tool_inventory(bus, session_id)
159
+ ops = bus.context[:operations_children] || {}
160
+ descriptors = []
161
+ index = {}
162
+ ops.each do |name, capsule|
163
+ next unless capsule.respond_to?(:tool_descriptor)
164
+ desc = capsule.tool_descriptor
165
+ next unless offer_tools?(session_id, desc)
166
+ descriptors << desc
167
+ index[desc.name] = capsule
168
+ end
169
+ [descriptors, index]
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../executors/fiber_executor"
4
+ require_relative "../executors/thread_executor"
5
+
6
+ module VSM
7
+ class Operations
8
+ EXECUTORS = {
9
+ fiber: Executors::FiberExecutor,
10
+ thread: Executors::ThreadExecutor
11
+ }.freeze
12
+
13
+ def observe(bus); end
14
+
15
+ def handle(message, bus:, children:, **)
16
+ return false unless message.kind == :tool_call
17
+
18
+ name = message.payload[:tool].to_s
19
+ tool_capsule = children.fetch(name) { raise "unknown tool capsule: #{name}" }
20
+ mode = tool_capsule.respond_to?(:execution_mode) ? tool_capsule.execution_mode : :fiber
21
+ executor = EXECUTORS.fetch(mode)
22
+
23
+ Async do
24
+ result = executor.call(tool_capsule, message.payload[:args])
25
+ bus.emit Message.new(kind: :tool_result, payload: result, corr_id: message.corr_id, meta: message.meta)
26
+ rescue => e
27
+ bus.emit Message.new(kind: :tool_result, payload: "ERROR: #{e.class}: #{e.message}", corr_id: message.corr_id, meta: message.meta)
28
+ end
29
+
30
+ true
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ require "async"
3
+
4
+ module VSM
5
+ module Runtime
6
+ def self.start(capsule, ports: [])
7
+ Async do |task|
8
+ capsule.run
9
+ ports.each do |p|
10
+ p.egress_subscribe if p.respond_to?(:egress_subscribe)
11
+ task.async { p.loop } if p.respond_to?(:loop)
12
+ end
13
+ task.sleep
14
+ end
15
+ end
16
+ end
17
+ end
18
+
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ module ActsAsTool
4
+ def self.included(base) = base.extend(ClassMethods)
5
+
6
+ module ClassMethods
7
+ def tool_name(value = nil); @tool_name = value if value; @tool_name; end
8
+ def tool_description(value = nil); @tool_description = value if value; @tool_description; end
9
+ def tool_schema(value = nil); @tool_schema = value if value; @tool_schema; end
10
+ end
11
+
12
+ def tool_descriptor
13
+ VSM::Tool::Descriptor.new(
14
+ name: self.class.tool_name,
15
+ description: self.class.tool_description,
16
+ schema: self.class.tool_schema
17
+ )
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class ToolCapsule
4
+ include ActsAsTool
5
+ attr_writer :governance
6
+ def governance = @governance || (raise "governance not injected")
7
+ # Subclasses implement:
8
+ # def run(args) ... end
9
+ # Optional:
10
+ # def execution_mode = :fiber | :thread
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ module Tool
4
+ Descriptor = Struct.new(:name, :description, :schema, keyword_init: true) do
5
+ def to_openai_tool
6
+ { type: "function", function: { name:, description:, parameters: schema } }
7
+ end
8
+ def to_anthropic_tool
9
+ { name:, description:, input_schema: schema }
10
+ end
11
+ def to_gemini_tool
12
+ { name:, description:, parameters: schema }
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/vsm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vsm
4
- VERSION = "0.0.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/vsm.rb CHANGED
@@ -1,7 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "async"
4
+ require "async/queue"
5
+
3
6
  require_relative "vsm/version"
4
7
 
8
+ require_relative "vsm/message"
9
+ require_relative "vsm/async_channel"
10
+ require_relative "vsm/homeostat"
11
+ require_relative "vsm/observability/ledger"
12
+
13
+ require_relative "vsm/roles/operations"
14
+ require_relative "vsm/roles/coordination"
15
+ require_relative "vsm/roles/intelligence"
16
+ require_relative "vsm/roles/governance"
17
+ require_relative "vsm/roles/identity"
18
+
19
+ require_relative "vsm/tool/descriptor"
20
+ require_relative "vsm/tool/acts_as_tool"
21
+ require_relative "vsm/tool/capsule"
22
+
23
+ require_relative "vsm/meta"
24
+
25
+ require_relative "vsm/executors/fiber_executor"
26
+ require_relative "vsm/executors/thread_executor"
27
+
28
+ require_relative "vsm/capsule"
29
+ require_relative "vsm/dsl"
30
+ require_relative "vsm/port"
31
+ require_relative "vsm/runtime"
32
+
33
+ require_relative "vsm/drivers/openai/async_driver"
34
+ require_relative "vsm/drivers/anthropic/async_driver"
35
+ require_relative "vsm/drivers/gemini/async_driver"
36
+ require_relative "vsm/drivers/family"
37
+
38
+ require_relative "vsm/lens"
39
+
40
+ # Optional/built-in ports and MCP integration
41
+ require_relative "vsm/ports/chat_tty"
42
+ require_relative "vsm/ports/mcp/server_stdio"
43
+ require_relative "vsm/mcp/jsonrpc"
44
+ require_relative "vsm/mcp/client"
45
+ require_relative "vsm/mcp/remote_tool_capsule"
46
+ require_relative "vsm/dsl_mcp"
47
+
5
48
  module Vsm
6
49
  class Error < StandardError; end
7
50
  # Your code goes here...