phronomy 0.7.1 → 0.9.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -45
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +11 -3
  6. data/benchmark/bench_regression.rb +11 -11
  7. data/benchmark/bench_token_estimator.rb +5 -5
  8. data/benchmark/bench_tool_schema.rb +2 -2
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +268 -403
  11. data/lib/phronomy/agent/checkpoint.rb +118 -0
  12. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  13. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  14. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  15. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  17. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  18. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  19. data/lib/phronomy/agent/fsm.rb +1 -1
  20. data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
  21. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  22. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  23. data/lib/phronomy/agent/react_agent.rb +43 -37
  24. data/lib/phronomy/agent/runner.rb +2 -2
  25. data/lib/phronomy/agent/shared_state.rb +2 -2
  26. data/lib/phronomy/agent/tool_executor.rb +108 -0
  27. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  28. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  29. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  30. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  31. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  32. data/lib/phronomy/concurrency/deadline.rb +65 -0
  33. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
  34. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  35. data/lib/phronomy/configuration.rb +0 -6
  36. data/lib/phronomy/context.rb +2 -8
  37. data/lib/phronomy/eval/runner.rb +4 -0
  38. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  39. data/lib/phronomy/event_loop.rb +7 -7
  40. data/lib/phronomy/invocation_context.rb +3 -3
  41. data/lib/phronomy/knowledge_source.rb +0 -5
  42. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  43. data/lib/phronomy/llm_context_window/assembler.rb +191 -0
  44. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  45. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  46. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  47. data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
  48. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
  49. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  50. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
  51. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  52. data/lib/phronomy/runtime.rb +20 -6
  53. data/lib/phronomy/task_group.rb +1 -1
  54. data/lib/phronomy/tool.rb +3 -4
  55. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  56. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  57. data/lib/phronomy/tools/vector_search.rb +70 -0
  58. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  59. data/lib/phronomy/vector_store/async_backend.rb +4 -4
  60. data/lib/phronomy/vector_store/base.rb +2 -2
  61. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  62. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  63. data/lib/phronomy/vector_store/in_memory.rb +12 -2
  64. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  65. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  66. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  67. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  68. data/lib/phronomy/vector_store/pgvector.rb +2 -2
  69. data/lib/phronomy/vector_store/redis_search.rb +2 -2
  70. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  71. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  72. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  73. data/lib/phronomy/vector_store.rb +14 -2
  74. data/lib/phronomy/version.rb +1 -1
  75. data/lib/phronomy/workflow_context.rb +8 -0
  76. data/lib/phronomy/workflow_runner.rb +11 -131
  77. data/lib/phronomy.rb +2 -0
  78. data/scripts/api_snapshot.rb +11 -9
  79. metadata +44 -46
  80. data/lib/phronomy/async_queue.rb +0 -155
  81. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  82. data/lib/phronomy/cancellation_scope.rb +0 -123
  83. data/lib/phronomy/cancellation_token.rb +0 -133
  84. data/lib/phronomy/concurrency_gate.rb +0 -155
  85. data/lib/phronomy/context/assembler.rb +0 -143
  86. data/lib/phronomy/context/compaction_context.rb +0 -111
  87. data/lib/phronomy/context/trigger_context.rb +0 -39
  88. data/lib/phronomy/context/trim_context.rb +0 -75
  89. data/lib/phronomy/deadline.rb +0 -63
  90. data/lib/phronomy/embeddings/base.rb +0 -39
  91. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  92. data/lib/phronomy/embeddings.rb +0 -11
  93. data/lib/phronomy/fsm_session.rb +0 -247
  94. data/lib/phronomy/knowledge_source/base.rb +0 -54
  95. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  96. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  97. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  98. data/lib/phronomy/loader/base.rb +0 -25
  99. data/lib/phronomy/loader/csv_loader.rb +0 -56
  100. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  101. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  102. data/lib/phronomy/loader.rb +0 -13
  103. data/lib/phronomy/prompt_template.rb +0 -96
  104. data/lib/phronomy/splitter/base.rb +0 -47
  105. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  106. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  107. data/lib/phronomy/splitter.rb +0 -12
  108. data/lib/phronomy/tool/base.rb +0 -644
  109. data/lib/phronomy/tool/scope_policy.rb +0 -50
  110. data/lib/phronomy/tool_executor.rb +0 -106
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Context
5
- # Read-only context passed to the +on_compaction_trigger+ callback.
6
- #
7
- # The callback inspects the current message list and budget, then returns
8
- # a truthy value to trigger compaction or a falsy value to skip it.
9
- #
10
- # No mutations are allowed through this object; use CompactionContext
11
- # (passed to +on_compact+) for actual modifications.
12
- #
13
- # @example Trigger compaction when messages exceed 80% of the input budget
14
- # on_compaction_trigger do |ctx|
15
- # limit = ctx.budget&.available(used: 0) || Float::INFINITY
16
- # ctx.total_tokens > limit * 0.8
17
- # end
18
- class TriggerContext
19
- # @return [Array<Hash>] frozen snapshot of message elements
20
- # each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
21
- attr_reader :message_elements
22
-
23
- # @return [Phronomy::Context::TokenBudget, nil] token budget for this invocation
24
- attr_reader :budget
25
-
26
- # @return [Integer] total estimated token count of all message elements
27
- attr_reader :total_tokens
28
-
29
- # @param message_elements [Array<Hash>]
30
- # @param budget [Phronomy::Context::TokenBudget, nil]
31
- # @api private
32
- def initialize(message_elements:, budget:)
33
- @message_elements = message_elements.dup.freeze
34
- @budget = budget
35
- @total_tokens = message_elements.sum { |e| e[:tokens] }
36
- end
37
- end
38
- end
39
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Context
5
- # Context object passed to the +on_trim+ callback registered on an agent class.
6
- #
7
- # The callback receives a TrimContext and may call #remove to drop specific
8
- # messages from the conversation before the LLM is called. Changes affect
9
- # only the current invocation; the underlying memory store is not modified.
10
- #
11
- # Message elements are identified by a +:seq+ integer that is assigned
12
- # sequentially (0-based) when messages are loaded from memory each turn.
13
- #
14
- # @example Remove the oldest two messages when the budget is tight
15
- # on_trim do |ctx|
16
- # if ctx.total_tokens > ctx.budget.available(used: 0) * 0.9
17
- # seqs_to_drop = ctx.message_elements.first(2).map { |e| e[:seq] }
18
- # ctx.remove(seqs_to_drop)
19
- # end
20
- # end
21
- class TrimContext
22
- # @return [Phronomy::Context::TokenBudget, nil] token budget for this invocation
23
- attr_reader :budget
24
-
25
- # @return [Integer] total estimated token count of all current message elements
26
- attr_reader :total_tokens
27
-
28
- # @param message_elements [Array<Hash>]
29
- # each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
30
- # @param budget [Phronomy::Context::TokenBudget, nil]
31
- # @api private
32
- def initialize(message_elements:, budget:)
33
- @message_elements = message_elements.dup
34
- @budget = budget
35
- recalculate!
36
- end
37
-
38
- # Returns a snapshot of the current message elements (defensive copy).
39
- # Each element is a Hash with +:seq+, +:message+, +:tokens+, and +:role+.
40
- #
41
- # @return [Array<Hash>]
42
- # @api private
43
- def message_elements
44
- @message_elements.dup
45
- end
46
-
47
- # Remove messages identified by seq numbers.
48
- # Calling this multiple times accumulates removals.
49
- #
50
- # @param seqs [Integer, Array<Integer>] seq number(s) to remove
51
- # @return [self]
52
- # @api private
53
- def remove(seqs)
54
- seqs_set = Array(seqs).to_set
55
- @message_elements.reject! { |e| seqs_set.include?(e[:seq]) }
56
- recalculate!
57
- self
58
- end
59
-
60
- # Convenience: returns the plain message objects (without element metadata).
61
- #
62
- # @return [Array]
63
- # @api private
64
- def messages
65
- @message_elements.map { |e| e[:message] }
66
- end
67
-
68
- private
69
-
70
- def recalculate!
71
- @total_tokens = @message_elements.sum { |e| e[:tokens] }
72
- end
73
- end
74
- end
75
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # A point in time used as an upper bound for an operation.
5
- #
6
- # Uses the monotonic clock (+Process::CLOCK_MONOTONIC+) internally to avoid
7
- # skew from NTP adjustments or DST transitions.
8
- #
9
- # @example Create a 30-second deadline and check remaining time
10
- # deadline = Phronomy::Deadline.in(30)
11
- # sleep 1
12
- # deadline.remaining_seconds # => ~29.0
13
- # deadline.expired? # => false
14
- class Deadline
15
- # Creates a deadline that expires +seconds+ from now.
16
- #
17
- # @param seconds [Numeric] seconds from now until expiry
18
- # @return [Deadline]
19
- # @api private
20
- def self.in(seconds)
21
- new(Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds)
22
- end
23
-
24
- # @param monotonic_at [Float] absolute monotonic timestamp of expiry
25
- # @api private
26
- def initialize(monotonic_at)
27
- @monotonic_at = monotonic_at
28
- end
29
-
30
- # Returns +true+ when the deadline has passed.
31
- # @return [Boolean]
32
- # @api private
33
- def expired?
34
- Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_at
35
- end
36
-
37
- # Seconds remaining until expiry. Returns 0 when already expired.
38
- # @return [Float]
39
- # @api private
40
- def remaining_seconds
41
- remaining = @monotonic_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
- [remaining, 0.0].max
43
- end
44
-
45
- # Attaches this deadline to a {CancellationToken} by cancelling the token
46
- # when the deadline expires. Uses the Runtime timer queue (a single
47
- # background thread shared by all deadlines) instead of spawning one thread
48
- # per deadline.
49
- #
50
- # @param token [CancellationToken]
51
- # @param timer_queue [Runtime::TimerQueue, nil] queue to register with;
52
- # defaults to +Phronomy::Runtime.instance.timer_queue+
53
- # @return [self]
54
- # @api private
55
- def attach_to(token, timer_queue: Phronomy::Runtime.instance.timer_queue)
56
- seconds = remaining_seconds
57
- return self if seconds <= 0
58
-
59
- timer_queue.schedule(seconds: seconds) { token.cancel! }
60
- self
61
- end
62
- end
63
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Embeddings
5
- # Abstract interface for embedding adapters.
6
- #
7
- # Concrete implementations must override {#embed} and return a vector
8
- # as an +Array<Float>+.
9
- class Base
10
- # Embed the given text and return a vector representation.
11
- #
12
- # @param text [String] the text to embed
13
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
14
- # @return [Array<Float>] the embedding vector
15
- # @api public
16
- def embed(text, cancellation_token = nil)
17
- cancellation_token&.raise_if_cancelled!
18
- raise NotImplementedError, "#{self.class}#embed is not implemented"
19
- end
20
-
21
- # Submits an {#embed} call to {BlockingAdapterPool} and returns a
22
- # {BlockingAdapterPool::PendingOperation}.
23
- #
24
- # @param text [String]
25
- # @param cancellation_token [Phronomy::CancellationToken, nil]
26
- # @param timeout [Numeric, nil] seconds before the operation is abandoned
27
- # @return [BlockingAdapterPool::PendingOperation]
28
- # @api public
29
- def embed_async(text, cancellation_token = nil, timeout: nil)
30
- Phronomy::Runtime.instance.blocking_io.submit(
31
- timeout: timeout,
32
- cancellation_token: cancellation_token
33
- ) do
34
- embed(text, cancellation_token)
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Embeddings
5
- # Embeddings adapter backed by RubyLLM.
6
- #
7
- # Delegates to +RubyLLM.embed+ and returns the resulting vector as an
8
- # +Array<Float>+.
9
- #
10
- # @example Default model
11
- # embeddings = Phronomy::Embeddings::RubyLLMEmbeddings.new
12
- # vector = embeddings.embed("Hello, world!")
13
- #
14
- # @example Explicit model
15
- # embeddings = Phronomy::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
16
- # vector = embeddings.embed("Hello, world!")
17
- class RubyLLMEmbeddings < Base
18
- # @param model [String, nil] embedding model identifier; nil uses the RubyLLM default
19
- # @param provider [Symbol, nil] provider override (e.g. :openai); nil uses the RubyLLM default
20
- # @param assume_model_exists [Boolean] when true, skips RubyLLM model-registry validation
21
- # (useful for locally hosted models not in the registry)
22
- # @api public
23
- def initialize(model: nil, provider: nil, assume_model_exists: false)
24
- @model = model
25
- @provider = provider
26
- @assume_model_exists = assume_model_exists
27
- end
28
-
29
- # Embed text via RubyLLM.
30
- #
31
- # @param text [String]
32
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
33
- # @return [Array<Float>]
34
- # @api public
35
- def embed(text, cancellation_token = nil)
36
- cancellation_token&.raise_if_cancelled!
37
- opts = {}
38
- opts[:model] = @model if @model
39
- opts[:provider] = @provider if @provider
40
- opts[:assume_model_exists] = true if @assume_model_exists
41
- RubyLLM.embed(text, **opts).vectors
42
- end
43
- end
44
- end
45
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Embeddings adapters for converting text into vector representations.
5
- #
6
- # Sub-classes are auto-loaded by Zeitwerk:
7
- # Phronomy::Embeddings::Base
8
- # Phronomy::Embeddings::RubyLLMEmbeddings
9
- module Embeddings
10
- end
11
- end
@@ -1,247 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Event-driven execution wrapper for a single workflow run.
5
- #
6
- # Created by WorkflowRunner and registered with EventLoop. All public methods
7
- # are called from the EventLoop thread — FSMSession is NOT thread-safe and must
8
- # not be accessed concurrently from multiple threads.
9
- #
10
- # == Lifecycle
11
- #
12
- # register(session) → EventLoop posts :start → session.start
13
- # ↓ (auto-transition present)
14
- # EventLoop posts :state_completed → session.handle
15
- # ↓ (repeat)
16
- # session posts :finished or :halted
17
- # ↓
18
- # EventLoop pushes ctx to completion_queue → caller unblocks
19
- #
20
- # == Async IO pattern (EventLoop mode only)
21
- #
22
- # When a state has no auto-transition and is not a wait_state, but has an
23
- # external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
24
- # the FSMSession stays registered in the EventLoop and waits for that event.
25
- # The entry action is expected to spawn an IO thread that posts the event back:
26
- #
27
- # entry :fetching, ->(ctx) {
28
- # Thread.new {
29
- # ctx.result = http.get(ctx.url)
30
- # Phronomy::EventLoop.instance.post(
31
- # Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
32
- # )
33
- # }
34
- # }
35
- # transition from: :fetching, on: :fetch_done, to: :process
36
- class FSMSession
37
- FINISH = WorkflowRunner::FINISH
38
-
39
- # @return [String] workflow thread_id (matches WorkflowContext#thread_id)
40
- attr_reader :id
41
-
42
- # @param id [String]
43
- # @param context [Object] includes Phronomy::WorkflowContext
44
- # @param entry_point [Symbol] initial state name
45
- # @param entry_actions [Hash] { state_name => [callable, ...] }
46
- # @param auto_state_set [Hash] { state_name => true }
47
- # @param declared_states [Array<Symbol>] all action state names
48
- # @param wait_state_names [Array<Symbol>]
49
- # @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
50
- # @param phase_machine_class [Class] state_machines-backed phase tracker class
51
- # @param recursion_limit [Integer]
52
- # @param action_timeouts [Hash] { state_name => seconds }
53
- # @param resume_event [Symbol, nil] external event to fire when resuming
54
- # @param resume_phase [Symbol, nil] wait state name to resume from
55
- # @api private
56
- def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
57
- declared_states:, wait_state_names:, external_events:, phase_machine_class:,
58
- recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
59
- @id = id
60
- @ctx = context
61
- @entry_point = entry_point
62
- @entry_actions = entry_actions
63
- @auto_state_set = auto_state_set
64
- @declared_states = declared_states
65
- @wait_state_names = wait_state_names
66
- @external_events = external_events
67
- @phase_machine_class = phase_machine_class
68
- @recursion_limit = recursion_limit
69
- @action_timeouts = action_timeouts
70
- @resume_event = resume_event
71
- @resume_phase = resume_phase
72
- @step = 0
73
- @done = false
74
- @current_state = nil
75
- @tracker = nil
76
- end
77
-
78
- # Begins workflow execution. Called by EventLoop on :start event.
79
- def start
80
- if @resume_event
81
- # Resume from wait state: position tracker at the wait state, then fire the
82
- # external event. state_machines fires before_transition (exit) and
83
- # after_transition (entry) callbacks, so both actions execute here.
84
- @current_state = @resume_phase
85
- @tracker = build_tracker(@current_state)
86
- @tracker.context = @ctx
87
- fire_and_advance!(@resume_event)
88
- else
89
- # Fresh start: state_machines does not fire callbacks on initialization,
90
- # so we invoke the entry action for the initial state manually.
91
- @current_state = @entry_point
92
- @tracker = build_tracker(@current_state)
93
- @tracker.context = @ctx
94
- (@entry_actions[@current_state] || []).each do |c|
95
- result = c.call(@ctx)
96
- if result.is_a?(Phronomy::Task)
97
- # Awaitable action: spawn a task to await without blocking EventLoop.
98
- @tracker.async_pending = true
99
- session_id = @id
100
- current_state_name = @current_state
101
- timeout_secs = @action_timeouts[current_state_name]
102
- Phronomy::Runtime.instance.spawn(name: "fsm-await-#{session_id}") do
103
- if timeout_secs
104
- if result.join(timeout_secs).nil?
105
- result.cancel!
106
- raise Phronomy::ActionTimeoutError,
107
- "Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
108
- end
109
- end
110
- task_result = result.await
111
- if task_result.is_a?(Phronomy::WorkflowContext)
112
- event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
113
- else
114
- event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
115
- end
116
- rescue => e
117
- event_loop.post(Event.new(type: :error, target_id: session_id, payload: e))
118
- end
119
- break # Only one async action at a time per state
120
- elsif result.is_a?(Phronomy::WorkflowContext)
121
- @ctx = result
122
- end
123
- end
124
- @tracker.context = @ctx
125
- advance_or_halt unless @tracker.async_pending
126
- end
127
- rescue => e
128
- finish_with_error(e)
129
- end
130
-
131
- # Processes an event dispatched from EventLoop.
132
- # Called for :state_completed, :action_completed, and all user-defined external events.
133
- #
134
- # @param event [Phronomy::Event]
135
- # @api private
136
- def handle(event)
137
- return if @done
138
-
139
- if event.type == :action_completed
140
- # An awaitable entry action completed: update context and advance.
141
- @ctx = event.payload if event.payload.is_a?(Phronomy::WorkflowContext)
142
- @tracker.context = @ctx
143
- @tracker.async_pending = false # Reset flag set by start or fire_and_advance!
144
- advance_or_halt
145
- return
146
- end
147
-
148
- fire_and_advance!(event.type)
149
- rescue => e
150
- finish_with_error(e)
151
- end
152
-
153
- private
154
-
155
- # Fires event_name on the phase tracker, updates @current_state, then
156
- # calls advance_or_halt to decide what to do next.
157
- def fire_and_advance!(event_name)
158
- if @step >= @recursion_limit
159
- raise Phronomy::RecursionLimitError,
160
- "Recursion limit (#{@recursion_limit}) exceeded"
161
- end
162
-
163
- fire_event!(@tracker, event_name, @current_state)
164
- @ctx = @tracker.context
165
- next_phase = @tracker.phase.to_sym
166
- # When next_phase == @current_state, no transition matched → treat as terminal.
167
- @current_state = (next_phase == @current_state) ? FINISH : next_phase
168
- @step += 1
169
-
170
- # If an entry action returned a Task, the after_transition callback set
171
- # async_pending = true and spawned a thread. Skip advance_or_halt — the
172
- # background thread will post :action_completed or :state_completed.
173
- if @tracker.async_pending
174
- @tracker.async_pending = false
175
- return
176
- end
177
-
178
- advance_or_halt
179
- end
180
-
181
- # Determines the next action after the FSM has entered @current_state.
182
- def advance_or_halt
183
- return finish! if @current_state == FINISH
184
-
185
- if @wait_state_names.include?(@current_state)
186
- return halt!
187
- end
188
-
189
- if @auto_state_set.key?(@current_state)
190
- event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
191
- return
192
- end
193
-
194
- if has_external_event_from?(@current_state)
195
- # Async IO pattern: the entry action spawned an IO thread that will post
196
- # an external event back. Stay registered; do nothing here.
197
- return
198
- end
199
-
200
- # No transition declared — validate the state is known, then treat as terminal.
201
- unless @declared_states.include?(@current_state)
202
- raise ArgumentError, "State #{@current_state.inspect} is not defined"
203
- end
204
-
205
- finish!
206
- end
207
-
208
- def finish!
209
- @done = true
210
- @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
211
- event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
212
- end
213
-
214
- def halt!
215
- @done = true
216
- @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
217
- event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
218
- end
219
-
220
- def finish_with_error(err)
221
- @done = true
222
- event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
223
- end
224
-
225
- def fire_event!(tracker, event_name, from_state)
226
- return if tracker.send(event_name)
227
-
228
- raise ArgumentError,
229
- "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
230
- "Ensure at least one guard matches or add a fallback (no-guard) transition."
231
- end
232
-
233
- def has_external_event_from?(state)
234
- @external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
235
- end
236
-
237
- def build_tracker(from_state)
238
- machine = @phase_machine_class.new
239
- machine.instance_variable_set(:@phase, from_state.to_s)
240
- machine
241
- end
242
-
243
- def event_loop
244
- Phronomy::EventLoop.instance
245
- end
246
- end
247
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module KnowledgeSource
5
- # Abstract base class for all KnowledgeSource implementations.
6
- #
7
- # Subclasses must implement #fetch(query:) and return an Array of chunk Hashes.
8
- # Each chunk Hash must contain:
9
- # :content [String] the text to inject into the context
10
- # :type [Symbol] semantic tag (e.g. :static, :rag, :entity)
11
- class Base
12
- # Retrieve knowledge chunks relevant to the given query.
13
- #
14
- # @param query [String, nil] the current user input used to select relevant chunks
15
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional token; raises CancellationError when cancelled
16
- # @return [Array<Hash>] array of { content: String, type: Symbol }
17
- # @api public
18
- def fetch(query: nil, cancellation_token: nil)
19
- cancellation_token&.raise_if_cancelled!
20
- raise NotImplementedError, "#{self.class}#fetch is not implemented"
21
- end
22
-
23
- # Submits a {#fetch} call to {BlockingAdapterPool} and returns a
24
- # {BlockingAdapterPool::PendingOperation}.
25
- # Callers can fan out multiple fetches in parallel and await them all.
26
- #
27
- # @param query [String, nil]
28
- # @param cancellation_token [Phronomy::CancellationToken, nil]
29
- # @param timeout [Numeric, nil] seconds before the operation is abandoned
30
- # @return [BlockingAdapterPool::PendingOperation]
31
- # @api public
32
- def fetch_async(query: nil, cancellation_token: nil, timeout: nil)
33
- Phronomy::Runtime.instance.blocking_io.submit(
34
- timeout: timeout,
35
- cancellation_token: cancellation_token
36
- ) do
37
- fetch(query: query, cancellation_token: cancellation_token)
38
- end
39
- end
40
-
41
- # Returns true when this source's content is considered static (i.e. does
42
- # not change between agent invocations). Static sources are eligible for
43
- # fingerprint-based caching in ContextVersionCache.
44
- #
45
- # Override in subclasses that return fixed content.
46
- #
47
- # @return [Boolean]
48
- # @api public
49
- def static?
50
- false
51
- end
52
- end
53
- end
54
- end
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module KnowledgeSource
5
- # A KnowledgeSource that extracts named-entity facts from conversation history.
6
- #
7
- # This is the knowledge-injection counterpart of the old EntityMemory.
8
- # It scans saved user messages with a regex heuristic (no LLM call) and
9
- # returns the discovered facts as a single knowledge chunk tagged :entity.
10
- #
11
- # EntityKnowledge is stateful: it accumulates extracted facts via #update(messages:)
12
- # which should be called each time new messages are saved.
13
- #
14
- # Supported extraction patterns (case-insensitive):
15
- # "my name is Alice" → { name: "Alice" }
16
- # "I am Alice" → { identity: "Alice" }
17
- # "I'm a software engineer" → { occupation: "software engineer" }
18
- # "I work at / for Acme" → { workplace: "Acme" }
19
- # "I live in Tokyo" → { location: "Tokyo" }
20
- # "I'm from Tokyo" → { location: "Tokyo" }
21
- # "I like / love Ruby" → { preference: "Ruby" }
22
- #
23
- # @example
24
- # ks = Phronomy::KnowledgeSource::EntityKnowledge.new
25
- # ks.update(messages: chat_messages)
26
- # agent.invoke("What is my name?", config: { knowledge_sources: [ks] })
27
- class EntityKnowledge < Base
28
- PATTERNS = [
29
- [:name, /\bmy name is\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
30
- [:identity, /\bI\s+am\s+([A-Z][A-Za-z0-9 \-']+)/],
31
- [:occupation, /\bI(?:'m| am) a(?:n)?\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
32
- [:workplace, /\bI (?:work|worked) (?:at|for|in)\s+([A-Za-z0-9][A-Za-z0-9 \-'.&,]*)/i],
33
- [:location, /\bI live in\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
34
- [:location, /\bI(?:'m| am) from\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
35
- [:preference, /\bI (?:like|love|enjoy)\s+([A-Za-z][A-Za-z0-9 \-']*)/i]
36
- ].freeze
37
-
38
- def initialize
39
- @entities = {}
40
- end
41
-
42
- # Scan messages and accumulate entity facts.
43
- # Call this after saving a new set of messages (e.g. from a ConversationManager save hook).
44
- #
45
- # @param messages [Array] message objects responding to #role and #content
46
- # @api public
47
- def update(messages:)
48
- messages.each do |msg|
49
- next unless msg.role.to_sym == :user
50
-
51
- extract(msg.content.to_s).each { |key, value| @entities[key] = value }
52
- end
53
- end
54
-
55
- # Returns a single chunk containing all known entity facts in XML context format.
56
- # Returns an empty array when no entities have been discovered.
57
- #
58
- # @param query [String, nil] unused — entity knowledge is always fully injected
59
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
60
- # @return [Array<Hash>]
61
- # @api public
62
- def fetch(query: nil, cancellation_token: nil)
63
- cancellation_token&.raise_if_cancelled!
64
- return [] if @entities.empty?
65
-
66
- lines = @entities.map { |key, value| "- #{key}: #{value}" }.join("\n")
67
- content = <<~CONTENT.chomp
68
- Known facts about the user:
69
- #{lines}
70
- CONTENT
71
- [{content: content, type: :entity}]
72
- end
73
-
74
- # Returns the current entity store (primarily for testing).
75
- #
76
- # @return [Hash]
77
- # @api public
78
- def entities
79
- @entities.dup
80
- end
81
-
82
- private
83
-
84
- def extract(text)
85
- found = {}
86
- PATTERNS.each do |key, pattern|
87
- if (match = text.match(pattern))
88
- value = match[1].strip.sub(/[.!?]\s+.*$/, "").gsub(/[.,;!?]+$/, "")
89
- found[key] = value unless value.empty?
90
- end
91
- end
92
- found
93
- end
94
- end
95
- end
96
- end