phronomy 0.1.4 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d95954b46d12542673b5a319b338c7733d579b72105499a55dc4251628bc807f
4
- data.tar.gz: 174341a0e329d861066d475b062260c3d78fac86da3d024ebc1594d7a37ec348
3
+ metadata.gz: c0aaadfedad1ee8b4afa1efb2e205a626a20cd636f268e711dce29128c84b6fe
4
+ data.tar.gz: f781f66ae3d570caca771d2b5579874169acef5eeed7f6cf99474a4c82fd077a
5
5
  SHA512:
6
- metadata.gz: ee299b8d67fec8cb268683ffe672daab04a5c5b4794728dbeea3877e6c5216cefe91f292acac977d7293b837cd0b159445d58a87f925781a26f24438faecd010
7
- data.tar.gz: 6da71943dc65b3671f5bd34ff18509a8ee2e0b12bfb5fcb206df77ba2c7345c9fd1b59dec90088dc3e8588e9327cb162046a803a9ac7cd405f4d30fc6712ebc3
6
+ metadata.gz: 73793efee9cf2c0cb81828fc86927181edca2b4d3fa830a6cefa176b30fd70c57c75e71173d57b51d385377ec3795f1d8cb42e25c3650be40eff543fc2f856fd
7
+ data.tar.gz: b7dd9cc538e478774d46cf7823e4f5e52b599a02fdd1edf300439287dabfe2fc9a90e93f50647fffa246f7f3c90bd4abf6cf258e9723af1222f836d89125dc59
data/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ---
9
+
10
+ ## [Unreleased]
11
+
12
+ ### Added
13
+
14
+ - **`Phronomy::Graph::Context`** module — canonical module for defining workflow
15
+ context classes (replaces the removed `Phronomy::Graph::State`).
16
+ - **`Phronomy::Graph.register_context_class`** — registers context classes for
17
+ deserialization from external stores (Redis, DB).
18
+ - **`Phronomy::Workflow.define`** DSL — primary high-level API for declaring
19
+ stateful workflows (`state`, `wait_state`, `event`, `after`, `initial`).
20
+ - **`Phronomy::Graph::WorkflowRunner`** — state-machine execution engine backing
21
+ the Workflow DSL. Replaces the removed `CompiledGraph`.
22
+ - **`app.send_event(event, config:)`** — event-driven resume for workflows halted
23
+ at a `wait_state`.
24
+ - **`state.halted?`** — returns `true` when the workflow is paused at a `wait_state`.
25
+ - **`state.phase`** — single source of truth for execution state.
26
+
27
+ ### Removed
28
+
29
+ - `Phronomy::Graph::StateGraph` / `CompiledGraph` — use `Phronomy::Workflow.define`.
30
+ - `Phronomy::Graph::State` — use `Phronomy::Graph::Context`.
31
+ - `Phronomy::Graph.register_state_class` — use `register_context_class`.
32
+ - `state.current_nodes` / `state.halted_before` — use `state.phase` / `state.halted?`.
33
+ - `compiled.interrupt_before` / `compiled.interrupt_after` — use `wait_state` + `event`.
34
+ - `compiled.resume` — use `app.send_event`.
35
+
36
+ ---
37
+
38
+ ## [0.2.0] - 2026-05-13
39
+
40
+ ### Added
41
+
42
+ - `Phronomy::Graph::WorkflowRunner` — state_machines-based execution engine
43
+ (introduced as the internal successor to `CompiledGraph`).
44
+ - `state.phase` — single source of truth for graph execution state (replaces
45
+ `current_nodes` + `halted_before` dual attributes).
46
+ - `state.halted?` — returns `true` when the graph is paused.
47
+ - `CompiledGraph#add_wait_state` — declared a named wait state that halts
48
+ automatically when reached (later superseded by `wait_state` DSL in `Workflow.define`).
49
+ - `CompiledGraph#send_event(state:, event:, input: nil)` — event-driven resume API
50
+ (later superseded by `app.send_event`).
51
+
52
+ ### Removed
53
+
54
+ - `ParallelNode` and `add_parallel_node` DSL. Use `Thread.new` or
55
+ `Concurrent::Future` at the application level instead.
56
+ - `Phronomy::Graph::TimeoutError` (was only used by `ParallelNode`).
data/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Phronomy
2
2
 
3
3
  **Phronomy** is a Ruby AI agent framework inspired by open-source AI agent frameworks.
4
- It provides composable building blocks — Graphs, Agents, and Memory — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
4
+ It provides composable building blocks — Workflows, Agents, and Memory — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
5
5
 
6
6
  ## Features
7
7
 
8
- - **Graph** — Build stateful, branching agent workflows with interrupt/resume support
9
- - **Graph Parallel Node** — Execute independent graph branches concurrently with configurable merge and error policies
8
+ - **Workflow** — Build stateful, branching agent workflows with wait_state/send_event support
9
+ - **Workflow Parallel Node** — Execute independent workflow branches concurrently using application-level Ruby threads
10
10
  - **Agent** — ReAct-style tool-calling agents with memory and guardrails
11
11
  - **Before-Completion Hook** — Three-tier (global / class / instance) LLM parameter injection before each chat request
12
12
  - **Memory** — Window, summary, ActiveRecord-backed, semantic, and composite conversation memory
@@ -70,31 +70,39 @@ result = ResearchAgent.new.invoke("What happened in AI research this week?")
70
70
  puts result[:output]
71
71
  ```
72
72
 
73
- ### Graph — Stateful workflow with interrupt/resume
73
+ ### Workflow — Stateful workflow with wait_state/send_event
74
74
 
75
75
  ```ruby
76
- class ReviewState
77
- include Phronomy::Graph::State
76
+ class ReviewContext
77
+ include Phronomy::WorkflowContext
78
78
  field :draft, type: :replace
79
79
  field :feedback, type: :replace
80
80
  field :approved, type: :replace, default: false
81
81
  end
82
82
 
83
- graph = Phronomy::Graph::StateGraph.new(ReviewState)
84
- graph.add_node(:write) { |s| { draft: Writer.call(s) } }
85
- graph.add_node(:review) { |s| { feedback: Reviewer.call(s.draft) } }
86
- graph.add_node(:finalize) { |s| { approved: true } }
87
- graph.add_edge(:write, :review)
88
- graph.add_edge(:review, :finalize)
89
- graph.set_entry_point(:write)
90
-
91
- # Register an interrupt callback before the :finalize node
92
- graph.interrupt_before(:finalize) do |state|
93
- puts "Draft ready for human review: #{state.draft}"
83
+ app = Phronomy::Workflow.define(ReviewContext) do
84
+ initial :write
85
+ state :write, action: ->(s) { s.merge(draft: Writer.call(s)) }
86
+ state :review, action: ->(s) { s.merge(feedback: Reviewer.call(s.draft)) }
87
+ wait_state :awaiting_approval # halts here for human decision
88
+ state :finalize, action: ->(s) { s.merge(approved: true) }
89
+ after :write, to: :review
90
+ after :review, to: :awaiting_approval
91
+ after :finalize, to: :__finish__
92
+ event :approve, from: :awaiting_approval, to: :finalize
93
+ event :reject, from: :awaiting_approval, to: :write
94
94
  end
95
95
 
96
- compiled = graph.compile
97
- compiled.invoke({ draft: "" }, config: { thread_id: "doc-1" })
96
+ Phronomy.configure { |c| c.default_state_store = Phronomy::StateStore::InMemory.new }
97
+
98
+ # First run — halts at :awaiting_approval
99
+ state = app.invoke({ draft: "" }, config: { thread_id: "doc-1" })
100
+ puts "Halted: #{state.halted?}" # => true
101
+ puts "Draft: #{state.draft}"
102
+
103
+ # Resume after human approval
104
+ final = app.send_event(:approve, config: { thread_id: "doc-1" })
105
+ puts "Approved: #{final.approved}" # => true
98
106
  ```
99
107
 
100
108
  ### Multi-Agent — Agent-as-Tool pattern
@@ -238,29 +246,32 @@ result.citations.each do |c|
238
246
  end
239
247
  ```
240
248
 
241
- ### Graph Parallel Node — Concurrent branches
249
+ ### Workflow Parallel Node — Concurrent branches
250
+
251
+ Phronomy does not provide a built-in parallel abstraction. Use application-level Ruby threads inside a `state` action:
242
252
 
243
253
  ```ruby
244
- class MyState
245
- include Phronomy::Graph::State
254
+ class EnrichContext
255
+ include Phronomy::WorkflowContext
246
256
  field :summary, type: :replace
247
- field :tags, type: :append, default: -> { [] }
257
+ field :tags, type: :append, default: -> { [] }
248
258
  end
249
259
 
250
- graph = Phronomy::Graph::StateGraph.new(MyState)
251
-
252
- graph.add_parallel_node(
253
- :enrich,
254
- ->(s) { { summary: Summarizer.call(s) } },
255
- ->(s) { { tags: Tagger.call(s) } },
256
- timeout: 10,
257
- on_error: :best_effort
258
- )
260
+ app = Phronomy::Workflow.define(EnrichContext) do
261
+ initial :enrich
262
+ state :enrich, action: ->(s) do
263
+ results = {}
264
+ threads = [
265
+ Thread.new { results[:summary] = Summarizer.call(s) },
266
+ Thread.new { results[:tags] = Tagger.call(s) }
267
+ ]
268
+ threads.each { |t| t.join(10) } # 10-second timeout
269
+ s.merge(summary: results[:summary], tags: Array(results[:tags]))
270
+ end
271
+ after :enrich, to: :__finish__
272
+ end
259
273
 
260
- graph.set_entry_point(:enrich)
261
- graph.add_edge(:enrich, Phronomy::Graph::StateGraph::FINISH)
262
- app = graph.compile
263
- app.invoke({}, config: { thread_id: "t1" })
274
+ state = app.invoke({}, config: { thread_id: "t1" })
264
275
  ```
265
276
 
266
277
  ### Output Parser — Structured LLM responses
@@ -483,8 +494,8 @@ bundle exec ruby NN_example_name/run.rb
483
494
  |---|-----------|----------------------|
484
495
  | 01 | `01_basic_chain/` | PromptTemplate → LLMChain pipeline |
485
496
  | 02 | `02_react_agent/` | ReAct tool-calling agent |
486
- | 03 | `03_state_graph/` | Stateful graph with interrupt/resume |
487
- | 04 | `04_interrupt_resume/` | Human-in-the-loop interrupt and resume |
497
+ | 03 | `03_state_graph/` | Stateful workflow with wait_state/send_event |
498
+ | 04 | `04_interrupt_resume/` | Human-in-the-loop wait_state and resume |
488
499
  | 05 | `05_multi_agent/` | Multi-agent coordination via Agent-as-Tool |
489
500
  | 06 | `06_guardrails/` | Input/output guardrails |
490
501
  | 07 | `07_tracing/` | Custom observability with Langfuse tracer |
@@ -49,7 +49,7 @@ cannot be delegated to the LLM must be enforced by phronomy or the application l
49
49
  | Layer | Responsibility | Status |
50
50
  |---|---|---|
51
51
  | LLM | Basic harmful-content avoidance (RLHF) | Model-dependent, not guaranteed |
52
- | **phronomy** | Intervention points, iteration limits, approval gates | ✅ `interrupt_before/after`, `requires_approval`, `max_iterations` — see `lib/phronomy/graph/compiled_graph.rb`, `lib/phronomy/agent/base.rb` |
52
+ | **phronomy** | Intervention points, iteration limits, approval gates | ✅ `wait_state`/`send_event`, `requires_approval`, `max_iterations` — see `lib/phronomy/workflow.rb`, `lib/phronomy/agent/base.rb` |
53
53
  | **phronomy** | Built-in guardrails (PII, prompt injection) | ❌ Not implemented — **planned (Feature A)** |
54
54
  | Application | Concrete guardrail logic, approval workflows | Application responsibility |
55
55
 
@@ -82,7 +82,7 @@ cannot be delegated to the LLM must be enforced by phronomy or the application l
82
82
  | Layer | Responsibility | Status |
83
83
  |---|---|---|
84
84
  | LLM | Chain-of-thought generation | Prompt-dependent |
85
- | **phronomy** | Processing step recording via Graph and Tracing | ✅ Partial — `StateGraph`, `Tracing` |
85
+ | **phronomy** | Processing step recording via Graph and Tracing | ✅ Partial — `Workflow`/`WorkflowRunner`, `Tracing` |
86
86
  | Application | Explanation UI, CoT prompt design | Application responsibility |
87
87
 
88
88
  **Planned work:** None in this iteration.
@@ -184,8 +184,8 @@ attributed to users or sessions, which is a requirement for accountability under
184
184
  NIST AI RMF 3.4.
185
185
 
186
186
  **Design:**
187
- - `Agent::Base#invoke` and `CompiledGraph#invoke` already accept `config: {}` — see
188
- `lib/phronomy/agent/base.rb:367` and `lib/phronomy/graph/compiled_graph.rb`.
187
+ - `Agent::Base#invoke` and `WorkflowRunner#invoke` already accept `config: {}` — see
188
+ `lib/phronomy/agent/base.rb` and `lib/phronomy/graph/workflow_runner.rb`.
189
189
  - Add two new optional keys to `config:`:
190
190
  - `user_id:` (String | nil) — caller identity
191
191
  - `session_id:` (String | nil) — session / request identity
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Lightweight synchronous actor backed by a dedicated +Thread+ and a +Queue+.
5
+ #
6
+ # A caller submits work via {#call}, which blocks until the actor's thread
7
+ # finishes executing the block and then returns the result (or re-raises any
8
+ # exception that occurred inside the actor).
9
+ #
10
+ # === Reentrant safety
11
+ #
12
+ # If {#call} is invoked from within the actor's own thread (i.e. from inside
13
+ # a block that is already executing on this actor), the block is executed
14
+ # directly in the current thread instead of being pushed onto the queue.
15
+ # This prevents deadlocks in deeply nested call paths without requiring
16
+ # callers to track whether they are already "inside" the actor.
17
+ #
18
+ # === Usage
19
+ #
20
+ # actor = Phronomy::Actor.new
21
+ # result = actor.call { expensive_operation() } # blocks caller; runs on actor thread
22
+ # actor.stop # graceful shutdown
23
+ class Actor
24
+ def initialize
25
+ @queue = Queue.new
26
+ @thread = Thread.new do
27
+ loop do
28
+ task = @queue.pop
29
+ break if task == :stop
30
+ task.call
31
+ end
32
+ end
33
+ end
34
+
35
+ # Run +block+ on the actor's thread and return its result.
36
+ #
37
+ # If the current thread is already the actor's thread (reentrant call),
38
+ # the block is executed inline to prevent deadlocks.
39
+ #
40
+ # Any exception raised inside the block is captured and re-raised in the
41
+ # calling thread.
42
+ #
43
+ # @yield block to execute on the actor's thread
44
+ # @return the return value of the block
45
+ def call(&block)
46
+ return block.call if Thread.current == @thread
47
+
48
+ done = Queue.new
49
+ @queue.push(-> {
50
+ begin
51
+ done.push([true, block.call])
52
+ rescue => e
53
+ done.push([false, e])
54
+ end
55
+ })
56
+ success, value = done.pop
57
+ raise value unless success
58
+ value
59
+ end
60
+
61
+ # Send a +:stop+ sentinel to gracefully terminate the actor's thread.
62
+ # Pending tasks already in the queue will still be processed before
63
+ # the thread exits.
64
+ def stop
65
+ @queue.push(:stop)
66
+ end
67
+ end
68
+ end
@@ -412,21 +412,8 @@ module Phronomy
412
412
  # result = MyAgent.new.invoke("What is Ruby?")
413
413
  # puts result[:output]
414
414
  def invoke(input, config: {})
415
- policy = self.class._retry_policy
416
- attempt = 0
417
- begin
418
- invoke_once(input, config: config)
419
- rescue Phronomy::GuardrailError
420
- raise
421
- rescue
422
- if policy && attempt < policy[:times]
423
- wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
424
- self.class._sleep_proc.call(wait) if wait > 0
425
- attempt += 1
426
- retry
427
- end
428
- raise
429
- end
415
+ thread_id = config[:thread_id]
416
+ _run_in_thread_actor(thread_id) { _invoke_impl(input, config: config) }
430
417
  end
431
418
 
432
419
  # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
@@ -446,6 +433,76 @@ module Phronomy
446
433
  def stream(input, config: {}, &block)
447
434
  return invoke(input, config: config) unless block
448
435
 
436
+ thread_id = config[:thread_id]
437
+ _run_in_thread_actor(thread_id) { _stream_impl(input, config: config, &block) }
438
+ rescue => e
439
+ block&.call(StreamEvent.new(type: :error, payload: {error: e}))
440
+ raise
441
+ end
442
+
443
+ # Registers a callback that is invoked before executing any tool that has
444
+ # +requires_approval true+ set. The block receives the tool name (String)
445
+ # and the arguments Hash, and must return a truthy value to allow execution.
446
+ # Returning a falsy value causes the tool to return a denial message instead
447
+ # of executing.
448
+ #
449
+ # When no handler is registered, tools with +requires_approval+ execute
450
+ # without interruption (backward-compatible behaviour).
451
+ #
452
+ # @example
453
+ # agent = MyAgent.new
454
+ # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
455
+ # @return [self]
456
+ def on_approval_required(&block)
457
+ @approval_handler = block
458
+ self
459
+ end
460
+
461
+ # Attach a guardrail that validates input before every #invoke call.
462
+ # @param guardrail [Phronomy::Guardrail::InputGuardrail]
463
+ def add_input_guardrail(guardrail)
464
+ @input_guardrails ||= []
465
+ @input_guardrails << guardrail
466
+ self
467
+ end
468
+
469
+ # Attach a guardrail that validates output before it is returned.
470
+ # @param guardrail [Phronomy::Guardrail::OutputGuardrail]
471
+ def add_output_guardrail(guardrail)
472
+ @output_guardrails ||= []
473
+ @output_guardrails << guardrail
474
+ self
475
+ end
476
+
477
+ # Returns the {Context::ContextVersionCache} for the current thread.
478
+ # @api private
479
+ def context_version_cache
480
+ (Thread.current[:phronomy_context_version_caches] ||= {})[object_id]
481
+ end
482
+
483
+ private
484
+
485
+ # Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
486
+ def _invoke_impl(input, config: {})
487
+ policy = self.class._retry_policy
488
+ attempt = 0
489
+ begin
490
+ invoke_once(input, config: config)
491
+ rescue Phronomy::GuardrailError
492
+ raise
493
+ rescue
494
+ if policy && attempt < policy[:times]
495
+ wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
496
+ self.class._sleep_proc.call(wait) if wait > 0
497
+ attempt += 1
498
+ retry
499
+ end
500
+ raise
501
+ end
502
+ end
503
+
504
+ # Streaming implementation for #stream.
505
+ def _stream_impl(input, config: {}, &block)
449
506
  caller_meta = {}
450
507
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
451
508
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
@@ -528,47 +585,16 @@ module Phronomy
528
585
  block.call(StreamEvent.new(type: :done, payload: result))
529
586
  [result, usage]
530
587
  end
531
- rescue => e
532
- block&.call(StreamEvent.new(type: :error, payload: {error: e}))
533
- raise
534
588
  end
535
589
 
536
- # Registers a callback that is invoked before executing any tool that has
537
- # +requires_approval true+ set. The block receives the tool name (String)
538
- # and the arguments Hash, and must return a truthy value to allow execution.
539
- # Returning a falsy value causes the tool to return a denial message instead
540
- # of executing.
541
- #
542
- # When no handler is registered, tools with +requires_approval+ execute
543
- # without interruption (backward-compatible behaviour).
544
- #
545
- # @example
546
- # agent = MyAgent.new
547
- # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
548
- # @return [self]
549
- def on_approval_required(&block)
550
- @approval_handler = block
551
- self
552
- end
590
+ # Runs +block+ inside the {Phronomy::ThreadActorRegistry} Actor for
591
+ # +thread_id+. When +thread_id+ is nil the block executes on the calling thread.
592
+ def _run_in_thread_actor(thread_id, &block)
593
+ return block.call unless thread_id
553
594
 
554
- # Attach a guardrail that validates input before every #invoke call.
555
- # @param guardrail [Phronomy::Guardrail::InputGuardrail]
556
- def add_input_guardrail(guardrail)
557
- @input_guardrails ||= []
558
- @input_guardrails << guardrail
559
- self
595
+ Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
560
596
  end
561
597
 
562
- # Attach a guardrail that validates output before it is returned.
563
- # @param guardrail [Phronomy::Guardrail::OutputGuardrail]
564
- def add_output_guardrail(guardrail)
565
- @output_guardrails ||= []
566
- @output_guardrails << guardrail
567
- self
568
- end
569
-
570
- private
571
-
572
598
  # Performs a single (non-retried) invocation. Extracted so that #invoke can
573
599
  # wrap it in a retry loop without duplicating the LLM interaction logic.
574
600
  def invoke_once(input, config: {})
@@ -790,7 +816,9 @@ module Phronomy
790
816
  [instruction.to_s, *static_chunks.map { |c| c[:content] }].join("\0")
791
817
  )
792
818
 
793
- cache = (@_context_version_cache ||= Context::ContextVersionCache.new)
819
+ agent_id = object_id
820
+ cache = (Thread.current[:phronomy_context_version_caches] ||= {})[agent_id] ||=
821
+ Context::ContextVersionCache.new
794
822
  unless cache.valid?(fingerprint)
795
823
  parts = [instruction]
796
824
  static_chunks.each do |chunk|
@@ -2,20 +2,9 @@
2
2
 
3
3
  module Phronomy
4
4
  module Context
5
- # Caches the assembled static system prompt text per agent instance.
6
- #
7
- # The cache is keyed by a SHA-256 fingerprint computed from the agent's
8
- # instruction text and the content of all registered static knowledge
9
- # sources. When the fingerprint matches the stored value the previously
10
- # assembled system_text is reused without re-fetching any sources.
11
- #
12
- # A cache miss (fingerprint changed or first call) triggers a full
13
- # rebuild: instruction + static-knowledge XML tags are concatenated and
14
- # the result is stored alongside the new fingerprint.
15
- #
16
- # Each agent *instance* holds one cache object. The cache persists across
17
- # #invoke calls on the same instance, which is the typical usage pattern
18
- # for long-running agents.
5
+ # Caches the assembled static system prompt text keyed by a SHA-256
6
+ # fingerprint of the agent's instructions + static knowledge content.
7
+ # Each instance is owned by one thread (stored in +Thread.current+).
19
8
  class ContextVersionCache
20
9
  # @return [String, nil] last stored fingerprint
21
10
  attr_reader :fingerprint
@@ -27,46 +16,34 @@ module Phronomy
27
16
  attr_reader :system_tokens
28
17
 
29
18
  def initialize
30
- @mutex = Mutex.new
31
19
  @fingerprint = nil
32
20
  @system_text = nil
33
21
  @system_tokens = 0
34
22
  end
35
23
 
36
24
  # Returns true when the given fingerprint matches the stored one.
37
- # The check is performed under a mutex so that a concurrent #update cannot
38
- # expose a partially-written state where fingerprint is new but system_text
39
- # is still nil (Issue #55).
40
25
  #
41
26
  # @param fingerprint [String] SHA-256 hex digest to compare
42
27
  # @return [Boolean]
43
28
  def valid?(fingerprint)
44
- @mutex.synchronize do
45
- !@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
46
- end
29
+ !@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
47
30
  end
48
31
 
49
32
  # Update the cache with a new fingerprint and system text.
50
- # All three assignments are performed atomically under a mutex so that
51
- # concurrent readers never observe a partial state (Issue #55).
52
33
  #
53
34
  # @param fingerprint [String] new SHA-256 hex digest
54
35
  # @param system_text [String] fully assembled system prompt text
55
36
  def update(fingerprint:, system_text:)
56
- @mutex.synchronize do
57
- @fingerprint = fingerprint
58
- @system_text = system_text.to_s
59
- @system_tokens = TokenEstimator.estimate(@system_text)
60
- end
37
+ @fingerprint = fingerprint
38
+ @system_text = system_text.to_s
39
+ @system_tokens = TokenEstimator.estimate(@system_text)
61
40
  end
62
41
 
63
42
  # Clear all cached values (used for testing and forced invalidation).
64
43
  def reset
65
- @mutex.synchronize do
66
- @fingerprint = nil
67
- @system_text = nil
68
- @system_tokens = 0
69
- end
44
+ @fingerprint = nil
45
+ @system_text = nil
46
+ @system_tokens = 0
70
47
  end
71
48
  end
72
49
  end
@@ -48,16 +48,6 @@ module Phronomy
48
48
  @retrieval = retrieval
49
49
  @compression = compression
50
50
  @ttl = ttl
51
- # Per-thread mutexes allow concurrent saves for different thread_ids while
52
- # preventing races (duplicate compaction records) within the same thread_id.
53
- @thread_mutexes = {}
54
- @thread_mutexes_mutex = Mutex.new
55
- # Tracks the monotonically increasing next-seq per thread so that TTL
56
- # purges (which reduce raw.length) do not reset the sequence counter.
57
- # Protected by a dedicated mutex so concurrent saves for distinct
58
- # thread_ids do not race on the shared Hash (Issue #60).
59
- @raw_seq_hwm = {}
60
- @raw_seq_hwm_mutex = Mutex.new
61
51
  end
62
52
 
63
53
  # Load conversation messages for a thread, applying retrieval selection.
@@ -92,8 +82,8 @@ module Phronomy
92
82
  # @param thread_id [String]
93
83
  # @param messages [Array] full conversation history up to this point
94
84
  def save(thread_id:, messages:)
95
- thread_mutex(thread_id).synchronize do
96
- append_new_messages_unlocked(thread_id: thread_id, messages: messages)
85
+ @storage.with_thread_lock(thread_id: thread_id) do
86
+ append_new_messages(thread_id: thread_id, messages: messages)
97
87
  compress_and_save(thread_id: thread_id, messages: messages)
98
88
  end
99
89
  @retrieval.index(thread_id: thread_id, messages: messages) if @retrieval.respond_to?(:index)
@@ -135,36 +125,17 @@ module Phronomy
135
125
 
136
126
  private
137
127
 
138
- # Returns (or lazily creates) the per-thread mutex for +thread_id+.
139
- # The outer @thread_mutexes_mutex protects the hash from concurrent creation.
140
- def thread_mutex(thread_id)
141
- @thread_mutexes_mutex.synchronize do
142
- @thread_mutexes[thread_id] ||= Mutex.new
143
- end
144
- end
145
-
146
128
  # Append messages that are new since the last save to the raw history.
147
- # Must be called while holding the per-thread mutex (via thread_mutex).
129
+ # Must be called while holding the per-thread lock (via Storage#with_thread_lock).
148
130
  # Messages are append-only; existing raw entries are never modified.
149
131
  #
150
- # Uses a per-thread high-water-mark (HWM) to determine the next seq number.
151
- # The HWM is the maximum of:
152
- # - The highest seq stored in the raw store (correct after normal appends)
153
- # - The in-memory HWM (correct after TTL purge empties the raw store)
154
- # This prevents seq number collisions when TTL purge reduces raw.length.
155
- def append_new_messages_unlocked(thread_id:, messages:)
156
- raw = @storage.load_raw(thread_id: thread_id)
157
- # Derive the next seq from the raw store's high-water-mark seq when
158
- # entries are present. Fall back to the in-memory HWM when the raw
159
- # store has been partially or fully purged by TTL expiry.
160
- stored_next_seq = raw.any? ? raw.map { |e| e[:seq] }.max + 1 : nil
161
- hwm = @raw_seq_hwm_mutex.synchronize { @raw_seq_hwm[thread_id] }
162
- next_seq = [stored_next_seq, hwm].compact.max || 0
132
+ # The next seq number is derived from Storage#next_seq, which owns the
133
+ # high-water-mark counter. This survives TTL purges because Storage tracks
134
+ # the HWM independently of the stored raw entries.
135
+ def append_new_messages(thread_id:, messages:)
136
+ next_seq = @storage.next_seq(thread_id: thread_id)
163
137
  new_messages = messages[next_seq..]
164
- if new_messages&.any?
165
- @storage.append_raw(thread_id: thread_id, messages: new_messages, starting_seq: next_seq)
166
- @raw_seq_hwm_mutex.synchronize { @raw_seq_hwm[thread_id] = next_seq + new_messages.length }
167
- end
138
+ @storage.append_raw(thread_id: thread_id, messages: new_messages, starting_seq: next_seq) if new_messages&.any?
168
139
  end
169
140
 
170
141
  # Apply the configured compression strategy and persist the result.
@@ -28,7 +28,7 @@ module Phronomy
28
28
  @index = {} # id => message (insertion-ordered via Ruby Hash)
29
29
  @counter = 0
30
30
  @max_index_size = max_index_size
31
- @mutex = Mutex.new
31
+ @actor = Phronomy::Actor.new
32
32
  @indexed_object_ids = {} # thread_id => { object_id => true }
33
33
  end
34
34
 
@@ -43,14 +43,14 @@ module Phronomy
43
43
  def index(thread_id:, messages:)
44
44
  messages.each do |msg|
45
45
  # Fast path: skip already-indexed messages without calling embed.
46
- already_indexed = @mutex.synchronize do
46
+ already_indexed = @actor.call do
47
47
  (@indexed_object_ids[thread_id] ||= {})[msg.object_id]
48
48
  end
49
49
  next if already_indexed
50
50
 
51
51
  embedding = @embeddings.embed(msg.content.to_s)
52
- @mutex.synchronize do
53
- # Re-check inside lock to handle concurrent callers for the same thread.
52
+ @actor.call do
53
+ # Re-check inside Actor to handle concurrent callers for the same thread.
54
54
  indexed = (@indexed_object_ids[thread_id] ||= {})
55
55
  next if indexed[msg.object_id]
56
56
 
@@ -68,7 +68,7 @@ module Phronomy
68
68
  #
69
69
  # @param thread_id [String]
70
70
  def clear_index(thread_id:)
71
- @mutex.synchronize do
71
+ @actor.call do
72
72
  ids = @index.keys.select { |id| id.start_with?("#{thread_id}:") }
73
73
  ids.each do |id|
74
74
  @index.delete(id)
@@ -87,7 +87,7 @@ module Phronomy
87
87
  def select(messages, query: nil, thread_id: nil)
88
88
  if query && !query.strip.empty?
89
89
  query_embedding = @embeddings.embed(query)
90
- results = @mutex.synchronize { @store.search(query_embedding: query_embedding, k: @k * 3) }
90
+ results = @actor.call { @store.search(query_embedding: query_embedding, k: @k * 3) }
91
91
  results
92
92
  .select { |r| thread_id.nil? || r[:metadata][:thread_id] == thread_id }
93
93
  .first(@k)
@@ -100,7 +100,7 @@ module Phronomy
100
100
  private
101
101
 
102
102
  # Evicts the oldest index entry to enforce max_index_size.
103
- # Must be called inside @mutex.synchronize.
103
+ # Must be called inside the Actor.
104
104
  def evict_oldest!
105
105
  oldest_id = @index.keys.first
106
106
  return unless oldest_id