phronomy 0.8.0 → 0.9.1

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -4
  3. data/README.md +32 -41
  4. data/benchmark/baseline.json +1 -1
  5. data/benchmark/bench_agent_invoke.rb +1 -1
  6. data/benchmark/bench_context_assembler.rb +9 -1
  7. data/benchmark/bench_regression.rb +8 -8
  8. data/benchmark/bench_tool_schema.rb +2 -2
  9. data/benchmark/bench_vector_store.rb +1 -1
  10. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  11. data/lib/phronomy/agent/base.rb +328 -366
  12. data/lib/phronomy/agent/checkpoint.rb +30 -1
  13. data/lib/phronomy/agent/checkpoint_store.rb +97 -0
  14. data/lib/phronomy/agent/concerns/retryable.rb +1 -1
  15. data/lib/phronomy/agent/concerns/suspendable.rb +63 -8
  16. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  17. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  18. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  19. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  20. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  21. data/lib/phronomy/agent/shared_state.rb +2 -2
  22. data/lib/phronomy/agent/tool_executor.rb +1 -1
  23. data/lib/phronomy/concurrency/gate_registry.rb +0 -1
  24. data/lib/phronomy/configuration.rb +13 -6
  25. data/lib/phronomy/event_loop.rb +1 -18
  26. data/lib/phronomy/llm_context_window/assembler.rb +77 -44
  27. data/lib/phronomy/multi_agent/handoff.rb +4 -4
  28. data/lib/phronomy/multi_agent/orchestrator.rb +1 -1
  29. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -2
  30. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  31. data/lib/phronomy/runtime.rb +1 -2
  32. data/lib/phronomy/tool.rb +3 -4
  33. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +8 -9
  34. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  35. data/lib/phronomy/tools/vector_search.rb +70 -0
  36. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  37. data/lib/phronomy/vector_store/base.rb +89 -0
  38. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  39. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  40. data/lib/phronomy/vector_store/in_memory.rb +103 -0
  41. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  42. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  43. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  44. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  45. data/lib/phronomy/vector_store/pgvector.rb +127 -0
  46. data/lib/phronomy/vector_store/redis_search.rb +192 -0
  47. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  48. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  49. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  50. data/lib/phronomy/vector_store.rb +16 -4
  51. data/lib/phronomy/version.rb +1 -1
  52. data/lib/phronomy/workflow/fsm_session.rb +249 -0
  53. data/lib/phronomy/workflow/phase_machine_builder.rb +247 -0
  54. data/lib/phronomy/workflow_runner.rb +2 -2
  55. data/lib/phronomy.rb +10 -3
  56. data/scripts/api_snapshot.rb +11 -10
  57. metadata +31 -37
  58. data/lib/phronomy/agent/context/conversation/compaction_context.rb +0 -117
  59. data/lib/phronomy/agent/context/conversation/trigger_context.rb +0 -43
  60. data/lib/phronomy/agent/context/conversation/trim_context.rb +0 -82
  61. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +0 -45
  62. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +0 -51
  63. data/lib/phronomy/agent/context/knowledge/loader/base.rb +0 -31
  64. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +0 -62
  65. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +0 -82
  66. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +0 -28
  67. data/lib/phronomy/agent/context/knowledge/source/base.rb +0 -60
  68. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +0 -102
  69. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +0 -63
  70. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +0 -58
  71. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +0 -53
  72. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +0 -57
  73. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +0 -111
  74. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +0 -116
  75. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +0 -95
  76. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +0 -109
  77. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +0 -133
  78. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +0 -198
  79. data/lib/phronomy/agent/fsm.rb +0 -157
  80. data/lib/phronomy/agent/invocation_pipeline.rb +0 -99
  81. data/lib/phronomy/agent/lifecycle/fsm_session.rb +0 -251
  82. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +0 -249
  83. data/lib/phronomy/agent/react_agent.rb +0 -204
  84. data/lib/phronomy/embeddings.rb +0 -11
  85. data/lib/phronomy/loader.rb +0 -13
  86. data/lib/phronomy/splitter.rb +0 -12
  87. data/lib/phronomy/tool/base.rb +0 -685
  88. data/lib/phronomy/tool/scope_policy.rb +0 -50
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Phronomy
4
6
  module Agent
5
7
  # Encapsulates the suspended state of an agent invocation.
@@ -19,6 +21,18 @@ module Phronomy
19
21
  # end
20
22
  # puts result[:output]
21
23
  class Checkpoint
24
+ # @return [String] a globally unique identifier for this checkpoint;
25
+ # used as an idempotency key when guarding against duplicate resumes
26
+ attr_reader :checkpoint_id
27
+
28
+ # @return [String, nil] the fully-qualified name of the agent class that
29
+ # created this checkpoint (e.g. +"MyApp::ReviewAgent"+); used by the
30
+ # class-level +resume+ method to validate the correct agent is used
31
+ attr_reader :agent_class
32
+
33
+ # @return [Time] the UTC timestamp when this checkpoint was created
34
+ attr_reader :requested_at
35
+
22
36
  # @return [String, nil] the thread_id from the invocation config
23
37
  attr_reader :thread_id
24
38
 
@@ -41,6 +55,9 @@ module Phronomy
41
55
  # inject the tool result message on resume)
42
56
  attr_reader :pending_tool_call_id
43
57
 
58
+ # @param checkpoint_id [String] unique identifier; defaults to a new UUID
59
+ # @param agent_class [String, nil] fully-qualified agent class name
60
+ # @param requested_at [Time] when the checkpoint was created; defaults to +Time.now.utc+
44
61
  # @param thread_id [String, nil]
45
62
  # @param original_input [String, Hash] the input passed to the original #invoke call
46
63
  # @param messages [Array<RubyLLM::Message>]
@@ -48,7 +65,11 @@ module Phronomy
48
65
  # @param pending_tool_args [Hash]
49
66
  # @param pending_tool_call_id [String]
50
67
  # @api public
51
- def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:)
68
+ def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:,
69
+ checkpoint_id: SecureRandom.uuid, agent_class: nil, requested_at: Time.now.utc)
70
+ @checkpoint_id = checkpoint_id
71
+ @agent_class = agent_class
72
+ @requested_at = requested_at
52
73
  @thread_id = thread_id
53
74
  @original_input = original_input
54
75
  @messages = messages.dup.freeze
@@ -71,6 +92,9 @@ module Phronomy
71
92
  # @api public
72
93
  def to_h
73
94
  {
95
+ checkpoint_id: @checkpoint_id,
96
+ agent_class: @agent_class,
97
+ requested_at: @requested_at&.iso8601,
74
98
  thread_id: @thread_id,
75
99
  original_input: @original_input,
76
100
  messages: @messages.map { |m| serialize_message(m) },
@@ -99,7 +123,12 @@ module Phronomy
99
123
  end
100
124
  }
101
125
  messages = Array(h[:messages]).map { |m| deserialize_message(m) }
126
+ requested_at_raw = h[:requested_at]
127
+ requested_at = requested_at_raw ? Time.parse(requested_at_raw.to_s).utc : nil
102
128
  new(
129
+ checkpoint_id: h[:checkpoint_id]&.to_s || SecureRandom.uuid,
130
+ agent_class: h[:agent_class]&.to_s,
131
+ requested_at: requested_at || Time.now.utc,
103
132
  thread_id: h[:thread_id],
104
133
  original_input: h[:original_input],
105
134
  messages: messages,
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Default in-memory idempotency store for {Checkpoint} resume operations.
6
+ #
7
+ # Tracks consumed checkpoint IDs so that calling {Agent::Base#resume} twice
8
+ # with the same checkpoint raises {Phronomy::CheckpointAlreadyResumedError}
9
+ # instead of silently executing the approved tool a second time.
10
+ #
11
+ # This implementation is *not thread-safe*. It assumes a single agent instance
12
+ # is accessed from only one thread at a time, which is the expected usage pattern.
13
+ # Agent instances themselves are not thread-safe (state like +@messages+, +@config+
14
+ # is not protected), so concurrent calls to the same agent instance are unsupported.
15
+ #
16
+ # Each agent instance gets its own store by default, so no sharing occurs unless
17
+ # the caller explicitly assigns the same store object to multiple agents.
18
+ #
19
+ # For distributed environments (multiple processes or background jobs), swap this
20
+ # for a custom implementation backed by Redis, ActiveRecord, or another shared store.
21
+ # *Your custom store implementation is responsible for ensuring thread-safety* if
22
+ # your application shares the same store instance across multiple threads.
23
+ #
24
+ # @example Plugging in a custom store
25
+ # agent = MyAgent.new
26
+ # agent.checkpoint_store = MyRedis::CheckpointStore.new
27
+ #
28
+ # @example Duck-type contract required by any replacement
29
+ # # consumed?(checkpoint_id) => Boolean
30
+ # # consume!(checkpoint_id) => void; raises CheckpointAlreadyResumedError if duplicate
31
+ # # cleanup!(checkpoint_id) => void (optional); removes tracking for the checkpoint
32
+ # # clear! => void (optional); removes all tracked checkpoints
33
+ #
34
+ # @api public
35
+ class CheckpointStore
36
+ def initialize
37
+ @consumed = Set.new
38
+ end
39
+
40
+ # Returns +true+ if the given checkpoint ID has already been consumed.
41
+ #
42
+ # @param checkpoint_id [String]
43
+ # @return [Boolean]
44
+ # @api public
45
+ def consumed?(checkpoint_id)
46
+ @consumed.include?(checkpoint_id)
47
+ end
48
+
49
+ # Marks +checkpoint_id+ as consumed, or raises if it was already consumed.
50
+ #
51
+ # @param checkpoint_id [String]
52
+ # @raise [Phronomy::CheckpointAlreadyResumedError]
53
+ # @return [void]
54
+ # @api public
55
+ def consume!(checkpoint_id)
56
+ if @consumed.include?(checkpoint_id)
57
+ raise Phronomy::CheckpointAlreadyResumedError,
58
+ "checkpoint #{checkpoint_id} has already been resumed"
59
+ end
60
+ @consumed.add(checkpoint_id)
61
+ nil
62
+ end
63
+
64
+ # Removes tracking for a specific checkpoint ID.
65
+ #
66
+ # Use this to explicitly discard a checkpoint when the application
67
+ # determines it is no longer needed (e.g., user abandons an approval
68
+ # workflow).
69
+ #
70
+ # This method is optional in the duck-type contract. Custom store
71
+ # implementations may choose not to implement it.
72
+ #
73
+ # @param checkpoint_id [String]
74
+ # @return [void]
75
+ # @api public
76
+ def cleanup!(checkpoint_id)
77
+ @consumed.delete(checkpoint_id)
78
+ nil
79
+ end
80
+
81
+ # Removes all tracked checkpoint IDs.
82
+ #
83
+ # Use this for test cleanup, periodic maintenance, or application
84
+ # shutdown.
85
+ #
86
+ # This method is optional in the duck-type contract. Custom store
87
+ # implementations may choose not to implement it.
88
+ #
89
+ # @return [void]
90
+ # @api public
91
+ def clear!
92
+ @consumed.clear
93
+ nil
94
+ end
95
+ end
96
+ end
97
+ end
@@ -49,7 +49,7 @@ module Phronomy
49
49
 
50
50
  private
51
51
 
52
- # Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
52
+ # Retry loop for #invoke.
53
53
  def _invoke_impl(input, messages: [], thread_id: nil, config: {})
54
54
  # Fail fast when the token is already cancelled before any LLM call.
55
55
  if (token = config[:cancellation_token]) && token.cancelled?
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Phronomy
4
6
  module Agent
5
7
  module Concerns
@@ -47,6 +49,23 @@ module Phronomy
47
49
  @scope_policy = policy
48
50
  end
49
51
 
52
+ # Sets the idempotency store used to guard against duplicate resumes.
53
+ #
54
+ # The store must respond to:
55
+ # - +consumed?(checkpoint_id)+ ⇒ Boolean
56
+ # - +consume!(checkpoint_id)+ ⇒ void; raises {Phronomy::CheckpointAlreadyResumedError} on duplicate
57
+ #
58
+ # Defaults to a per-instance {Phronomy::Agent::CheckpointStore} (in-memory, not thread-safe).
59
+ # Assign a shared persistent store when resuming across processes (e.g. Redis-backed).
60
+ # Custom stores are responsible for ensuring thread-safety if shared across threads.
61
+ #
62
+ # @param store [#consumed?, #consume!]
63
+ # @return [void]
64
+ # @api public
65
+ def checkpoint_store=(store)
66
+ @checkpoint_store = store
67
+ end
68
+
50
69
  # Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
51
70
  #
52
71
  # This method reconstructs the conversation state captured at suspension
@@ -59,18 +78,23 @@ module Phronomy
59
78
  # to inject a denial message and let the LLM handle it gracefully
60
79
  # @param config [Hash] same runtime options as #invoke
61
80
  # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
81
+ # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint, messages: Array }+
82
+ # when a second approval-required tool is encountered during continuation
62
83
  # @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
84
+ # @raise [Phronomy::CheckpointAlreadyResumedError] when the checkpoint has already been consumed
63
85
  # @api private
64
86
  def resume(checkpoint, approved:, config: {})
87
+ # Guard against duplicate resumes using the idempotency store.
88
+ _checkpoint_store.consume!(checkpoint.checkpoint_id)
65
89
  # Build a fresh chat with all tools registered.
66
90
  chat = build_chat
67
91
 
68
- # Re-apply system instructions so the LLM has the same persona/context
69
- # as the original invocation. build_cached_system_text is memoised, so
70
- # a Proc- or PromptTemplate-based instructions block is re-evaluated
71
- # against the original input rather than using a stale cached value.
72
- system_text = build_cached_system_text(checkpoint.original_input)
73
- apply_instructions(chat, system_text) if system_text
92
+ # Re-apply system instructions and register tools so the LLM has the
93
+ # same persona/context as the original invocation. build_context
94
+ # includes all tool classes (static + handoff) via add_capability.
95
+ context = build_context(checkpoint.original_input, messages: [])
96
+ apply_instructions(chat, context[:system]) if context[:system]
97
+ (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
74
98
 
75
99
  # Restore the full conversation (history + user + assistant with tool call).
76
100
  checkpoint.messages.each { |msg| chat.messages << msg }
@@ -91,8 +115,30 @@ module Phronomy
91
115
  tool_call_id: checkpoint.pending_tool_call_id
92
116
  )
93
117
 
94
- # Continue the React loop.
95
- response = chat.complete
118
+ # Re-register the suspension hook so that any further requires_approval
119
+ # tools encountered during continuation are intercepted rather than
120
+ # executed without approval (cascading / chained approval scenario).
121
+ _register_suspension_hook!(chat)
122
+
123
+ # Continue the LLM loop. Rescue SuspendSignal so that a second
124
+ # approval-required tool produces a new checkpoint instead of running
125
+ # without consent.
126
+ begin
127
+ response = chat.complete
128
+ rescue SuspendSignal => signal
129
+ new_checkpoint = Checkpoint.new(
130
+ checkpoint_id: SecureRandom.uuid,
131
+ agent_class: self.class.name,
132
+ requested_at: Time.now.utc,
133
+ thread_id: checkpoint.thread_id,
134
+ original_input: checkpoint.original_input,
135
+ messages: chat.messages.dup,
136
+ pending_tool_name: signal.tool_name,
137
+ pending_tool_args: signal.args,
138
+ pending_tool_call_id: signal.tool_call_id
139
+ )
140
+ return {output: nil, suspended: true, checkpoint: new_checkpoint, messages: chat.messages}
141
+ end
96
142
 
97
143
  output = response.content
98
144
  usage = Phronomy::TokenUsage.from_tokens(response.tokens)
@@ -129,6 +175,15 @@ module Phronomy
129
175
  end
130
176
  end
131
177
  end
178
+
179
+ # Returns the checkpoint idempotency store for this instance, lazily
180
+ # initialising a default in-memory {Phronomy::Agent::CheckpointStore}.
181
+ #
182
+ # @return [#consumed?, #consume!]
183
+ # @api private
184
+ def _checkpoint_store
185
+ @checkpoint_store ||= CheckpointStore.new
186
+ end
132
187
  end
133
188
  end
134
189
  end