phronomy 0.2.2 → 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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -30
  3. data/README.md +26 -110
  4. data/lib/phronomy/agent/base.rb +127 -54
  5. data/lib/phronomy/agent/checkpoint.rb +53 -0
  6. data/lib/phronomy/agent/react_agent.rb +18 -28
  7. data/lib/phronomy/agent/suspend_signal.rb +35 -0
  8. data/lib/phronomy/agent.rb +2 -1
  9. data/lib/phronomy/configuration.rb +0 -24
  10. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
  11. data/lib/phronomy/railtie.rb +0 -6
  12. data/lib/phronomy/ruby_llm_patches.rb +20 -0
  13. data/lib/phronomy/tool/mcp_tool.rb +23 -26
  14. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
  15. data/lib/phronomy/trust_pipeline.rb +1 -2
  16. data/lib/phronomy/vector_store/redis_search.rb +4 -4
  17. data/lib/phronomy/version.rb +1 -1
  18. data/lib/phronomy/workflow.rb +4 -7
  19. data/lib/phronomy/workflow_runner.rb +1 -8
  20. data/lib/phronomy.rb +1 -0
  21. data/scripts/check_readme_ruby.rb +38 -0
  22. metadata +5 -33
  23. data/docs/trustworthy_ai_enhancements.md +0 -332
  24. data/lib/phronomy/active_record/acts_as.rb +0 -48
  25. data/lib/phronomy/active_record/checkpoint.rb +0 -20
  26. data/lib/phronomy/active_record/extensions.rb +0 -14
  27. data/lib/phronomy/active_record/message.rb +0 -20
  28. data/lib/phronomy/actor.rb +0 -68
  29. data/lib/phronomy/memory/compression/base.rb +0 -37
  30. data/lib/phronomy/memory/compression/summary.rb +0 -107
  31. data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
  32. data/lib/phronomy/memory/compression.rb +0 -11
  33. data/lib/phronomy/memory/conversation_manager.rb +0 -213
  34. data/lib/phronomy/memory/retrieval/base.rb +0 -22
  35. data/lib/phronomy/memory/retrieval/composite.rb +0 -76
  36. data/lib/phronomy/memory/retrieval/recent.rb +0 -35
  37. data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
  38. data/lib/phronomy/memory/retrieval.rb +0 -12
  39. data/lib/phronomy/memory/storage/active_record.rb +0 -248
  40. data/lib/phronomy/memory/storage/base.rb +0 -155
  41. data/lib/phronomy/memory/storage/in_memory.rb +0 -152
  42. data/lib/phronomy/memory/storage.rb +0 -11
  43. data/lib/phronomy/memory.rb +0 -21
  44. data/lib/phronomy/rails/agent_job.rb +0 -75
  45. data/lib/phronomy/state_store/active_record.rb +0 -76
  46. data/lib/phronomy/state_store/base.rb +0 -112
  47. data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
  48. data/lib/phronomy/state_store/encryptor/base.rb +0 -34
  49. data/lib/phronomy/state_store/encryptor.rb +0 -16
  50. data/lib/phronomy/state_store/file.rb +0 -85
  51. data/lib/phronomy/state_store/in_memory.rb +0 -53
  52. data/lib/phronomy/state_store/redis.rb +0 -70
  53. data/lib/phronomy/state_store.rb +0 -9
  54. data/lib/phronomy/thread_actor_registry.rb +0 -85
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Encapsulates the suspended state of an agent invocation.
6
+ #
7
+ # A Checkpoint is returned as the +:checkpoint+ key of the result hash when
8
+ # an approval-required tool is encountered and no synchronous
9
+ # on_approval_required handler has been registered.
10
+ #
11
+ # Pass the checkpoint to Agent::Base#resume to continue execution after
12
+ # obtaining an approval decision from the user or an external system.
13
+ #
14
+ # @example Suspend and resume
15
+ # result = agent.invoke("Do task X")
16
+ # if result[:suspended]
17
+ # approved = prompt_user(result[:checkpoint].pending_tool_name)
18
+ # result = agent.resume(result[:checkpoint], approved: approved)
19
+ # end
20
+ # puts result[:output]
21
+ class Checkpoint
22
+ # @return [String, nil] the thread_id from the invocation config
23
+ attr_reader :thread_id
24
+
25
+ # @return [Array<RubyLLM::Message>] conversation messages up to and including
26
+ # the assistant message that requested the pending tool call
27
+ attr_reader :messages
28
+
29
+ # @return [String] the name of the tool awaiting approval
30
+ attr_reader :pending_tool_name
31
+
32
+ # @return [Hash] the arguments the LLM passed to the pending tool
33
+ attr_reader :pending_tool_args
34
+
35
+ # @return [String] the tool_call_id from the LLM response (required to
36
+ # inject the tool result message on resume)
37
+ attr_reader :pending_tool_call_id
38
+
39
+ # @param thread_id [String, nil]
40
+ # @param messages [Array<RubyLLM::Message>]
41
+ # @param pending_tool_name [String]
42
+ # @param pending_tool_args [Hash]
43
+ # @param pending_tool_call_id [String]
44
+ def initialize(thread_id:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:)
45
+ @thread_id = thread_id
46
+ @messages = messages.dup.freeze
47
+ @pending_tool_name = pending_tool_name
48
+ @pending_tool_args = pending_tool_args
49
+ @pending_tool_call_id = pending_tool_call_id
50
+ end
51
+ end
52
+ end
53
+ end
@@ -18,18 +18,11 @@ module Phronomy
18
18
  # Run input guardrails before any LLM interaction.
19
19
  run_input_guardrails!(input)
20
20
 
21
- memory = config[:memory]
22
- thread_id = config[:thread_id]
21
+ config[:thread_id]
23
22
  max_iter = self.class.max_iterations
24
23
 
25
- # Seed with persisted messages when memory is provided.
26
- initial_messages = if memory && thread_id
27
- load_from_memory(memory, thread_id: thread_id, query: extract_message(input))
28
- else
29
- []
30
- end
31
-
32
- messages = initial_messages.dup
24
+ # Seed with app-managed conversation history when provided.
25
+ messages = Array(config[:messages]).dup
33
26
  user_asked = false
34
27
  total_usage = Phronomy::TokenUsage.zero
35
28
  iterations_exhausted = true
@@ -45,12 +38,8 @@ module Phronomy
45
38
  end
46
39
  end
47
40
 
48
- save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
49
-
50
- # Fall back to the last message that carries non-nil content. This
41
+ # Fall back to the last message
51
42
  # guards against the case where the final message is a tool-call or
52
- # tool-result message (content == nil) when max_iterations is
53
- # exhausted before the model produces a text reply.
54
43
  output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
55
44
 
56
45
  # Run output guardrails before returning to the caller.
@@ -80,17 +69,10 @@ module Phronomy
80
69
  trace("agent.invoke", input: input, **caller_meta) do |_span|
81
70
  run_input_guardrails!(input)
82
71
 
83
- memory = config[:memory]
84
- thread_id = config[:thread_id]
72
+ config[:thread_id]
85
73
  max_iter = self.class.max_iterations
86
74
 
87
- initial_messages = if memory && thread_id
88
- load_from_memory(memory, thread_id: thread_id, query: extract_message(input))
89
- else
90
- []
91
- end
92
-
93
- messages = initial_messages.dup
75
+ messages = Array(config[:messages]).dup
94
76
  user_asked = false
95
77
  total_usage = Phronomy::TokenUsage.zero
96
78
  iterations_exhausted = true
@@ -106,8 +88,6 @@ module Phronomy
106
88
  end
107
89
  end
108
90
 
109
- save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
110
-
111
91
  # Fall back to the last message that carries non-nil content (same as
112
92
  # the non-streaming path above).
113
93
  output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
@@ -154,8 +134,18 @@ module Phronomy
154
134
  chat = build_chat
155
135
  messages.each { |m| chat.add_message(m) }
156
136
 
157
- chat.before_tool_call { |tc| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc})) }
158
- chat.after_tool_result { |tr| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tr})) }
137
+ current_tool_call = nil
138
+ chat.on_tool_call do |tc|
139
+ current_tool_call = tc
140
+ block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc}))
141
+ end
142
+ chat.on_tool_result do |tr|
143
+ block.call(StreamEvent.new(type: :tool_result, payload: {
144
+ tool_call_id: current_tool_call&.id,
145
+ tool_name: current_tool_call&.name,
146
+ tool_result: tr
147
+ }))
148
+ end
159
149
 
160
150
  # Run before_completion hooks before each LLM call in the streaming loop.
161
151
  run_before_completion_hooks!(chat, config)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Raised internally inside the on_tool_call hook when an approval-required
6
+ # tool is encountered and no synchronous on_approval_required handler has
7
+ # been registered. Caught by Agent::Base#invoke_once to produce a
8
+ # suspended result hash containing a Checkpoint.
9
+ #
10
+ # This class is intentionally NOT part of the public API. Callers should
11
+ # inspect the +:suspended+ key in the result hash returned by #invoke.
12
+ #
13
+ # @api private
14
+ class SuspendSignal < StandardError
15
+ # @return [String] the name of the tool that triggered the suspension
16
+ attr_reader :tool_name
17
+
18
+ # @return [Hash] the arguments the LLM passed to the tool
19
+ attr_reader :args
20
+
21
+ # @return [String] the tool_call_id from the LLM response
22
+ attr_reader :tool_call_id
23
+
24
+ # @param tool_name [String]
25
+ # @param args [Hash]
26
+ # @param tool_call_id [String]
27
+ def initialize(tool_name:, args:, tool_call_id:)
28
+ super("Agent suspended waiting for approval of tool: #{tool_name}")
29
+ @tool_name = tool_name
30
+ @args = args
31
+ @tool_call_id = tool_call_id
32
+ end
33
+ end
34
+ end
35
+ end
@@ -7,7 +7,8 @@ module Phronomy
7
7
  # type values:
8
8
  # :token — a content delta from the LLM (payload: { content: String })
9
9
  # :tool_call — the LLM requested a tool call (payload: { tool_call: Object })
10
- # :tool_result — a tool finished executing (payload: { tool_result: Object })
10
+ # :tool_result — a tool finished executing (payload: { tool_call_id: String, tool_name: String,
11
+ # tool_result: Object })
11
12
  # :done — the agent finished (payload: { output: String, messages: Array,
12
13
  # usage: TokenUsage })
13
14
  # :error — an unrecoverable error occurred (payload: { error: Exception })
@@ -16,20 +16,6 @@ module Phronomy
16
16
  # Default embedding model name
17
17
  attr_accessor :default_embedding_model
18
18
 
19
- # Default StateStore instance (nil = no persistence)
20
- attr_accessor :default_state_store
21
-
22
- # Default Memory instance
23
- attr_accessor :default_memory
24
-
25
- # When true, all memory backends write asynchronously via ActiveJob by default.
26
- # Individual instances can still override with their own async: option.
27
- # Requires ActiveJob to be available.
28
- attr_accessor :memory_async
29
-
30
- # ActiveJob queue name used for async memory writes (default: :default)
31
- attr_accessor :memory_job_queue
32
-
33
19
  # Tracer instance
34
20
  attr_accessor :tracer
35
21
 
@@ -47,20 +33,10 @@ module Phronomy
47
33
  # the tracing backend (OTel, Langfuse, etc.).
48
34
  attr_accessor :trace_pii
49
35
 
50
- # Maximum number of Actors that {ThreadActorRegistry} may hold simultaneously.
51
- # When the registry is full, the least-recently-used Actor is stopped and
52
- # evicted before a new one is created.
53
- # Defaults to +nil+ (no limit). Set to a positive integer for long-running
54
- # server processes that handle many distinct conversation threads.
55
- attr_accessor :max_actors
56
-
57
36
  def initialize
58
37
  @recursion_limit = 25
59
38
  @tracer = Phronomy::Tracing::NullTracer.new
60
- @memory_async = false
61
- @memory_job_queue = :default
62
39
  @trace_pii = true
63
- @max_actors = nil
64
40
  end
65
41
  end
66
42
  end
@@ -7,10 +7,10 @@ module Phronomy
7
7
  #
8
8
  # Four categories are supported and each can be individually toggled:
9
9
  #
10
- # - +:my_number+ Japanese My Number (12-digit national ID)
10
+ # - +:ssn+ US Social Security Numbers (###-##-####)
11
11
  # - +:credit_card+ — Credit / debit card numbers
12
12
  # - +:email+ — E-mail addresses
13
- # - +:phone+ — Japanese domestic phone numbers
13
+ # - +:phone+ — Phone numbers
14
14
  #
15
15
  # All four categories are active by default.
16
16
  #
@@ -24,13 +24,10 @@ module Phronomy
24
24
  class PIIPatternDetector < InputGuardrail
25
25
  # Recognised PII categories and their detection patterns.
26
26
  PATTERNS = {
27
- # Japanese My Number: 12 consecutive or grouped digits (4-4-4).
28
- # Matched candidates are additionally validated with the official check-digit
29
- # algorithm (JIS X 0076) to eliminate false positives from arbitrary 12-digit strings.
30
- my_number: {
31
- pattern: /(?<!\d)(?<!\d[- ])\d{4}[- ]?\d{4}[- ]?\d{4}(?![- ]?\d)/,
32
- label: "My Number",
33
- validate_my_number: true
27
+ # US Social Security Number: ###-##-#### (hyphens required).
28
+ ssn: {
29
+ pattern: /\b\d{3}-\d{2}-\d{4}\b/,
30
+ label: "SSN"
34
31
  },
35
32
  # Credit / debit card: 16 digits, optionally separated by spaces or hyphens.
36
33
  # Matched candidates are additionally validated with the Luhn algorithm
@@ -45,9 +42,10 @@ module Phronomy
45
42
  pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
46
43
  label: "email address"
47
44
  },
48
- # Japanese phone number: starts with 0, groups of 2-5 / 1-4 / 4 digits.
45
+ # Phone number: 3-digit area code, 3-4-digit exchange, 4-digit subscriber;
46
+ # optional E.164 country-code prefix (e.g. +1, +44).
49
47
  phone: {
50
- pattern: /\b0\d{1,4}[- ]?\d{1,4}[- ]?\d{4}\b/,
48
+ pattern: /(?:\+\d{1,3}[.\- ]?)?\(?\d{3}\)?[.\- ]?\d{3,4}[.\- ]?\d{4}\b/,
51
49
  label: "phone number"
52
50
  }
53
51
  }.freeze
@@ -55,7 +53,7 @@ module Phronomy
55
53
  ALL_CATEGORIES = PATTERNS.keys.freeze
56
54
 
57
55
  # @param detect [Array<Symbol>] categories to detect.
58
- # Defaults to all four: +:my_number+, +:credit_card+, +:email+, +:phone+.
56
+ # Defaults to all four: +:ssn+, +:credit_card+, +:email+, +:phone+.
59
57
  # @raise [ArgumentError] when an unknown category symbol is provided.
60
58
  def initialize(detect: ALL_CATEGORIES)
61
59
  unknown = Array(detect) - ALL_CATEGORIES
@@ -74,10 +72,6 @@ module Phronomy
74
72
  # Scan for all candidates then filter by Luhn check-digit validation.
75
73
  # This avoids false positives on arbitrary 16-digit strings (e.g. internal IDs).
76
74
  text.scan(entry[:pattern]).any? { |m| luhn_valid?(m.gsub(/[- ]/, "")) }
77
- elsif entry[:validate_my_number]
78
- # Scan for all candidates then apply the JIS X 0076 check-digit algorithm.
79
- # This avoids false positives on arbitrary 12-digit strings.
80
- text.scan(entry[:pattern]).any? { |m| my_number_valid?(m.gsub(/[- ]/, "")) }
81
75
  else
82
76
  text.match?(entry[:pattern])
83
77
  end
@@ -87,17 +81,6 @@ module Phronomy
87
81
 
88
82
  private
89
83
 
90
- # Returns true when +digits+ (a 12-character string of decimal digits) satisfies
91
- # the Japanese My Number check-digit algorithm defined in JIS X 0076.
92
- # The check digit is the 12th digit.
93
- def my_number_valid?(digits)
94
- weights = [6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2]
95
- total = weights.each_with_index.sum { |w, i| w * digits[i].to_i }
96
- remainder = total % 11
97
- check = (remainder <= 1) ? 0 : 11 - remainder
98
- check == digits[11].to_i
99
- end
100
-
101
84
  # Returns true when +digits+ (a string of decimal digits) satisfies the
102
85
  # Luhn check-digit algorithm used by payment card networks.
103
86
  def luhn_valid?(digits)
@@ -30,16 +30,10 @@ module Phronomy
30
30
 
31
31
  # Loads Phronomy::Rails::AgentJob when both ActionCable and ActiveJob are present.
32
32
  initializer "phronomy.agent_job" do
33
- if defined?(::ActionCable) && defined?(::ActiveJob)
34
- require "phronomy/rails/agent_job"
35
- end
36
33
  end
37
34
 
38
35
  # Loads Phronomy ActiveRecord extensions when ActiveRecord is available.
39
36
  initializer "phronomy.active_record", after: "active_record.initialize_database" do
40
- ActiveSupport.on_load(:active_record) do
41
- require "phronomy/active_record/extensions" if defined?(::ActiveRecord)
42
- end
43
37
  end
44
38
  end
45
39
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches for upstream ruby_llm bugs that have not yet been released.
4
+ # Remove each patch once the fix is available in a published gem version.
5
+
6
+ module RubyLLM
7
+ module Streaming
8
+ private
9
+
10
+ # Upstream ruby_llm <= 1.15.0 assumes the SSE error chunk always has two
11
+ # lines ("event: error\ndata: {...}") and uses a fixed index [1], which
12
+ # raises NoMethodError when some providers (e.g. Qwen) return a single-line
13
+ # chunk ("data: {...}"). This patch finds the data line by content instead.
14
+ def handle_error_chunk(chunk, env)
15
+ data_line = chunk.split("\n").find { |l| l.start_with?("data: ") } || chunk.split("\n")[0]
16
+ error_data = data_line.delete_prefix("data: ")
17
+ parse_error_from_json(error_data, env, "Failed to parse error chunk")
18
+ end
19
+ end
20
+ end
@@ -11,10 +11,11 @@ module Phronomy
11
11
  # A Phronomy::Tool::Base subclass that wraps a tool exposed by an external
12
12
  # MCP (Model Context Protocol) server.
13
13
  #
14
- # Currently supports the **stdio** transport only: the MCP server is launched
15
- # as a child process and communicates via newline-delimited JSON-RPC on stdin/stdout.
16
- #
17
- # HTTP/SSE transport support can be added later by subclassing Transport.
14
+ # Supports two transport schemes:
15
+ # - <b>"stdio://\<command\>"</b> — spawns a child process that communicates via
16
+ # newline-delimited JSON-RPC on stdin/stdout.
17
+ # - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> connects to a running
18
+ # HTTP/SSE MCP server using +net/http+.
18
19
  #
19
20
  # @example
20
21
  # web_search = Phronomy::Tool::McpTool.from_server(
@@ -31,6 +32,7 @@ module Phronomy
31
32
  # @param server_uri [String] URI of the MCP server.
32
33
  # Supported schemes:
33
34
  # - "stdio://<command>" — spawn a child process
35
+ # - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
34
36
  # @param tool_name [String] the tool name as registered in the MCP server
35
37
  # @return [McpTool] a configured subclass instance ready for use with an Agent
36
38
  def from_server(server_uri, tool_name:)
@@ -87,7 +89,6 @@ module Phronomy
87
89
  # Split the command string into an argv array so that Open3 executes
88
90
  # it directly without going through the shell, preventing injection.
89
91
  @command = Shellwords.split(command)
90
- @actor = Phronomy::Actor.new
91
92
  @stdin = nil
92
93
  @stdout = nil
93
94
  @stderr = nil
@@ -97,19 +98,17 @@ module Phronomy
97
98
 
98
99
  # Shut down the child process and close its IO streams.
99
100
  def close
100
- stderr_thread, wait_thr = @actor.call do
101
- @stdin&.close
102
- @stdout&.close
103
- @stderr&.close
104
- @stdin = nil
105
- @stdout = nil
106
- @stderr = nil
107
- t = [@stderr_thread, @wait_thr]
108
- @stderr_thread = nil
109
- @wait_thr = nil
110
- t
111
- end
112
- # Join outside the Actor to avoid blocking the Actor thread on slow joins.
101
+ @stdin&.close
102
+ @stdout&.close
103
+ @stderr&.close
104
+ @stdin = nil
105
+ @stdout = nil
106
+ @stderr = nil
107
+ stderr_thread = @stderr_thread
108
+ wait_thr = @wait_thr
109
+ @stderr_thread = nil
110
+ @wait_thr = nil
111
+ # Join outside the lock to avoid blocking on slow joins.
113
112
  stderr_thread&.join(1)
114
113
  wait_thr&.join(5)
115
114
  end
@@ -168,14 +167,12 @@ module Phronomy
168
167
  end
169
168
 
170
169
  def rpc_call(method, params)
171
- @actor.call do
172
- ensure_started!
173
- payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
174
- @stdin.puts(payload)
175
- raw = @stdout.gets
176
- raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
177
- JSON.parse(raw)
178
- end
170
+ ensure_started!
171
+ payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
172
+ @stdin.puts(payload)
173
+ raw = @stdout.gets
174
+ raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
175
+ JSON.parse(raw)
179
176
  end
180
177
 
181
178
  def parse_schema_params(properties)
@@ -36,7 +36,6 @@ module Phronomy
36
36
  @secret_key = secret_key
37
37
  @host = host.chomp("/")
38
38
  @http = nil
39
- @actor = Phronomy::Actor.new
40
39
  end
41
40
 
42
41
  # Returns a plain Hash that records the span start state.
@@ -90,13 +89,11 @@ module Phronomy
90
89
  req["Authorization"] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
91
90
  req.body = JSON.generate({batch: events})
92
91
 
93
- @actor.call do
94
- @http ||= build_http(uri)
95
- @http.request(req)
96
- end
92
+ @http ||= build_http(uri)
93
+ @http.request(req)
97
94
  rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e
98
95
  # Connection was reset; drop the cached connection and warn.
99
- @actor.call { @http = nil }
96
+ @http = nil
100
97
  warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
101
98
  nil
102
99
  rescue => e
@@ -94,7 +94,6 @@ module Phronomy
94
94
  @threshold = confidence_threshold.to_f
95
95
  @max_iterations = max_iterations.to_i
96
96
  @input_delimiter = input_delimiter
97
- @actor = Phronomy::Actor.new
98
97
  @compiled_graph = nil
99
98
  end
100
99
 
@@ -125,7 +124,7 @@ module Phronomy
125
124
 
126
125
  # Returns the compiled workflow, building and caching it on first call.
127
126
  def compiled_graph
128
- @actor.call { @compiled_graph ||= build_workflow }
127
+ @compiled_graph ||= build_workflow
129
128
  end
130
129
 
131
130
  def build_workflow
@@ -38,7 +38,7 @@ module Phronomy
38
38
  @index_name = index_name
39
39
  @dimension = dimension
40
40
  @index_created = false
41
- @actor = Phronomy::Actor.new
41
+ @mutex = Mutex.new
42
42
  end
43
43
 
44
44
  # @param id [String]
@@ -80,7 +80,7 @@ module Phronomy
80
80
  end
81
81
 
82
82
  def clear
83
- @actor.call do
83
+ @mutex.synchronize do
84
84
  begin
85
85
  @redis.call("FT.DROPINDEX", @index_name, "DD")
86
86
  rescue => e
@@ -94,8 +94,8 @@ module Phronomy
94
94
  private
95
95
 
96
96
  def ensure_index!(dim)
97
- @actor.call do
98
- next if @index_created
97
+ @mutex.synchronize do
98
+ return if @index_created
99
99
 
100
100
  @dimension ||= dim
101
101
  begin
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -53,11 +53,10 @@ module Phronomy
53
53
 
54
54
  # Defines a new Workflow.
55
55
  # @param context_class [Class] class that includes Phronomy::WorkflowContext
56
- # @param state_store [Object, nil] optional state store override (passed to WorkflowRunner)
57
56
  # @yield block evaluated in DSL context
58
57
  # @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
59
- def self.define(context_class, state_store: nil, &block)
60
- builder = Builder.new(context_class, state_store: state_store)
58
+ def self.define(context_class, &block)
59
+ builder = Builder.new(context_class)
61
60
  builder.instance_eval(&block)
62
61
  builder.build
63
62
  end
@@ -110,9 +109,8 @@ module Phronomy
110
109
  class Builder
111
110
  FINISH = Phronomy::WorkflowRunner::FINISH
112
111
 
113
- def initialize(context_class, state_store: nil)
112
+ def initialize(context_class)
114
113
  @context_class = context_class
115
- @state_store = state_store
116
114
  @initial = nil
117
115
  # { node_name => callable }
118
116
  @states = {}
@@ -210,8 +208,7 @@ module Phronomy
210
208
  route_transitions: route_transitions,
211
209
  external_events: external_events,
212
210
  entry_point: @initial || nodes.keys.first,
213
- wait_state_names: @wait_state_names,
214
- state_store: @state_store
211
+ wait_state_names: @wait_state_names
215
212
  )
216
213
 
217
214
  Workflow.new(runner)
@@ -38,7 +38,7 @@ module Phronomy
38
38
 
39
39
  def initialize(state_class:, nodes:, after_transitions:, route_transitions:,
40
40
  external_events:, entry_point:, wait_state_names: [],
41
- before_callbacks: {}, after_callbacks: {}, state_store: nil)
41
+ before_callbacks: {}, after_callbacks: {})
42
42
  @state_class = state_class
43
43
  @nodes = nodes
44
44
  @after_transitions = after_transitions # { from => to }
@@ -48,7 +48,6 @@ module Phronomy
48
48
  @wait_state_names = wait_state_names
49
49
  @before_callbacks = before_callbacks.dup
50
50
  @after_callbacks = after_callbacks.dup
51
- @state_store_override = state_store
52
51
  @phase_machine_class = build_phase_machine_class
53
52
  end
54
53
 
@@ -134,10 +133,6 @@ module Phronomy
134
133
 
135
134
  private
136
135
 
137
- def state_store
138
- @state_store_override || Phronomy.configuration.default_state_store
139
- end
140
-
141
136
  def run_graph(state, from_node: nil, recursion_limit: 25, &event_block)
142
137
  current_node = from_node || @entry_point
143
138
  tracker = new_phase_machine(current_node)
@@ -153,7 +148,6 @@ module Phronomy
153
148
  # Auto-halt at wait states: save context and return to caller.
154
149
  if @wait_state_names.include?(current_node)
155
150
  state.set_graph_metadata(thread_id: state.thread_id, phase: current_node)
156
- state_store&.save(state)
157
151
  return state
158
152
  end
159
153
 
@@ -195,7 +189,6 @@ module Phronomy
195
189
  end
196
190
 
197
191
  state.set_graph_metadata(thread_id: state.thread_id, phase: :__end__)
198
- state_store&.save(state)
199
192
  state
200
193
  end
201
194
 
data/lib/phronomy.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "zeitwerk"
4
4
  require "ruby_llm"
5
+ require_relative "phronomy/ruby_llm_patches"
5
6
 
6
7
  loader = Zeitwerk::Loader.for_gem
7
8
  loader.ignore(File.expand_path("generators", __dir__))
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extracts every ```ruby ... ``` block from README.md and runs `ruby -c` on each.
4
+ # Exits non-zero if any block has a syntax error.
5
+
6
+ require "tempfile"
7
+ require "open3"
8
+
9
+ readme_path = File.expand_path("../README.md", __dir__)
10
+ readme = File.read(readme_path)
11
+ blocks = readme.scan(/^```ruby\n(.*?)^```/m).map.with_index(1) { |(code), i| [i, code] }
12
+
13
+ puts "Checking #{blocks.size} Ruby code blocks in README.md..."
14
+
15
+ failures = []
16
+
17
+ blocks.each do |index, code|
18
+ Tempfile.create(["readme_block_#{index}", ".rb"]) do |f|
19
+ f.write(code)
20
+ f.flush
21
+ stdout, status = Open3.capture2e("ruby", "-c", f.path)
22
+ if status.success?
23
+ puts " OK block ##{index}"
24
+ else
25
+ failures << index
26
+ puts " FAIL block ##{index}"
27
+ puts stdout.gsub(f.path, "block ##{index}")
28
+ end
29
+ end
30
+ end
31
+
32
+ if failures.empty?
33
+ puts "All #{blocks.size} Ruby code blocks passed syntax check."
34
+ exit 0
35
+ else
36
+ puts "\n#{failures.size} block(s) failed syntax check: #{failures.join(", ")}"
37
+ exit 1
38
+ end