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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -4
- data/README.md +32 -41
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_agent_invoke.rb +1 -1
- data/benchmark/bench_context_assembler.rb +9 -1
- data/benchmark/bench_regression.rb +8 -8
- data/benchmark/bench_tool_schema.rb +2 -2
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
- data/lib/phronomy/agent/base.rb +328 -366
- data/lib/phronomy/agent/checkpoint.rb +30 -1
- data/lib/phronomy/agent/checkpoint_store.rb +97 -0
- data/lib/phronomy/agent/concerns/retryable.rb +1 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +63 -8
- data/lib/phronomy/agent/context/capability/base.rb +689 -0
- data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
- data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/shared_state.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +1 -1
- data/lib/phronomy/concurrency/gate_registry.rb +0 -1
- data/lib/phronomy/configuration.rb +13 -6
- data/lib/phronomy/event_loop.rb +1 -18
- data/lib/phronomy/llm_context_window/assembler.rb +77 -44
- data/lib/phronomy/multi_agent/handoff.rb +4 -4
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -1
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
- data/lib/phronomy/runtime.rb +1 -2
- data/lib/phronomy/tool.rb +3 -4
- data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +8 -9
- data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
- data/lib/phronomy/tools/vector_search.rb +70 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +89 -0
- data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
- data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
- data/lib/phronomy/vector_store/in_memory.rb +103 -0
- data/lib/phronomy/vector_store/loader/base.rb +27 -0
- data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
- data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
- data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
- data/lib/phronomy/vector_store/pgvector.rb +127 -0
- data/lib/phronomy/vector_store/redis_search.rb +192 -0
- data/lib/phronomy/vector_store/splitter/base.rb +49 -0
- data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
- data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
- data/lib/phronomy/vector_store.rb +16 -4
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/fsm_session.rb +249 -0
- data/lib/phronomy/workflow/phase_machine_builder.rb +247 -0
- data/lib/phronomy/workflow_runner.rb +2 -2
- data/lib/phronomy.rb +10 -3
- data/scripts/api_snapshot.rb +11 -10
- metadata +31 -37
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +0 -117
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +0 -43
- data/lib/phronomy/agent/context/conversation/trim_context.rb +0 -82
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +0 -45
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +0 -51
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +0 -31
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +0 -62
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +0 -82
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +0 -28
- data/lib/phronomy/agent/context/knowledge/source/base.rb +0 -60
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +0 -63
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +0 -53
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +0 -57
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +0 -111
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +0 -116
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +0 -95
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +0 -109
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +0 -133
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +0 -198
- data/lib/phronomy/agent/fsm.rb +0 -157
- data/lib/phronomy/agent/invocation_pipeline.rb +0 -99
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +0 -251
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +0 -249
- data/lib/phronomy/agent/react_agent.rb +0 -204
- data/lib/phronomy/embeddings.rb +0 -11
- data/lib/phronomy/loader.rb +0 -13
- data/lib/phronomy/splitter.rb +0 -12
- data/lib/phronomy/tool/base.rb +0 -685
- 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.
|
|
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
|
|
69
|
-
# as the original invocation.
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
#
|
|
95
|
-
|
|
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
|