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.
- checksums.yaml +4 -4
- data/README.md +35 -45
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_agent_invoke.rb +1 -1
- data/benchmark/bench_context_assembler.rb +11 -3
- data/benchmark/bench_regression.rb +11 -11
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +2 -2
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
- data/lib/phronomy/agent/base.rb +268 -403
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
- 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/instruction/prompt_template.rb +102 -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/fsm.rb +1 -1
- data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +43 -37
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/shared_state.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/eval/runner.rb +4 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +7 -7
- data/lib/phronomy/invocation_context.rb +3 -3
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
- data/lib/phronomy/llm_context_window/assembler.rb +191 -0
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
- data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
- data/lib/phronomy/runtime.rb +20 -6
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool.rb +3 -4
- data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
- data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
- data/lib/phronomy/tools/vector_search.rb +70 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store/async_backend.rb +4 -4
- data/lib/phronomy/vector_store/base.rb +2 -2
- 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 +12 -2
- 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 +2 -2
- data/lib/phronomy/vector_store/redis_search.rb +2 -2
- 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 +14 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_context.rb +8 -0
- data/lib/phronomy/workflow_runner.rb +11 -131
- data/lib/phronomy.rb +2 -0
- data/scripts/api_snapshot.rb +11 -9
- metadata +44 -46
- data/lib/phronomy/async_queue.rb +0 -155
- data/lib/phronomy/blocking_adapter_pool.rb +0 -435
- data/lib/phronomy/cancellation_scope.rb +0 -123
- data/lib/phronomy/cancellation_token.rb +0 -133
- data/lib/phronomy/concurrency_gate.rb +0 -155
- data/lib/phronomy/context/assembler.rb +0 -143
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/deadline.rb +0 -63
- data/lib/phronomy/embeddings/base.rb +0 -39
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/embeddings.rb +0 -11
- data/lib/phronomy/fsm_session.rb +0 -247
- data/lib/phronomy/knowledge_source/base.rb +0 -54
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/loader.rb +0 -13
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/splitter.rb +0 -12
- data/lib/phronomy/tool/base.rb +0 -644
- data/lib/phronomy/tool/scope_policy.rb +0 -50
- 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
|
data/lib/phronomy/deadline.rb
DELETED
|
@@ -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
|
data/lib/phronomy/embeddings.rb
DELETED
|
@@ -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
|
data/lib/phronomy/fsm_session.rb
DELETED
|
@@ -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
|