phronomy 0.6.0 → 0.7.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. metadata +83 -2
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Carries all per-invocation context values through the call stack.
5
+ #
6
+ # +InvocationContext+ is a plain value object (struct-like, frozen on
7
+ # creation) that replaces ad-hoc +Thread.current[...]+ propagation.
8
+ # Pass it explicitly wherever context needs to cross a method boundary
9
+ # or be handed to a child {Task} / {TaskGroup}.
10
+ #
11
+ # @example Build a context for a new agent invocation
12
+ # ctx = Phronomy::InvocationContext.new(
13
+ # thread_id: "conv-123",
14
+ # cancellation_token: Phronomy::CancellationToken.timeout_after(30),
15
+ # max_parallel_tools: 5
16
+ # )
17
+ # agent.invoke("Hello", invocation_context: ctx)
18
+ class InvocationContext
19
+ # @return [String, nil] conversation / workflow thread identifier
20
+ attr_reader :thread_id
21
+
22
+ # @return [String, nil] session identifier (e.g. Rails session id)
23
+ attr_reader :session_id
24
+
25
+ # @return [String, nil] end-user identifier for tracing / audit
26
+ attr_reader :user_id
27
+
28
+ # @return [CancellationToken, nil]
29
+ attr_reader :cancellation_token
30
+
31
+ # @return [Deadline, nil]
32
+ attr_reader :deadline
33
+
34
+ # @return [Object, nil] OpenTelemetry / tracing span
35
+ attr_reader :tracer_span
36
+
37
+ # @return [Integer, nil] max tokens the agent may consume this invocation
38
+ attr_reader :token_budget
39
+
40
+ # @return [Integer] maximum simultaneous tool calls (default: 10)
41
+ attr_reader :max_parallel_tools
42
+
43
+ # @return [Object, nil] approval policy applied before write-scope tools
44
+ attr_reader :approval_policy
45
+
46
+ # @return [Object, nil] redaction policy applied to tool args / results
47
+ attr_reader :redaction_policy
48
+
49
+ # @return [Hash, nil] per-provider concurrency / rate-limit overrides
50
+ attr_reader :provider_limits
51
+
52
+ # @return [String, nil] unique identifier for this task in the trace tree
53
+ attr_reader :task_id
54
+
55
+ # @return [String, nil] task_id of the parent span / task
56
+ attr_reader :parent_task_id
57
+
58
+ # @param thread_id [String, nil]
59
+ # @param session_id [String, nil]
60
+ # @param user_id [String, nil]
61
+ # @param cancellation_token [CancellationToken, nil]
62
+ # @param deadline [Deadline, nil]
63
+ # @param tracer_span [Object, nil]
64
+ # @param token_budget [Integer, nil]
65
+ # @param max_parallel_tools [Integer]
66
+ # @param approval_policy [Object, nil]
67
+ # @param redaction_policy [Object, nil]
68
+ # @param provider_limits [Hash, nil]
69
+ # @param task_id [String, nil]
70
+ # @param parent_task_id [String, nil]
71
+ # @api private
72
+ def initialize(
73
+ thread_id: nil,
74
+ session_id: nil,
75
+ user_id: nil,
76
+ cancellation_token: nil,
77
+ deadline: nil,
78
+ tracer_span: nil,
79
+ token_budget: nil,
80
+ max_parallel_tools: 10,
81
+ approval_policy: nil,
82
+ redaction_policy: nil,
83
+ provider_limits: nil,
84
+ task_id: nil,
85
+ parent_task_id: nil
86
+ )
87
+ @thread_id = thread_id
88
+ @session_id = session_id
89
+ @user_id = user_id
90
+ @cancellation_token = cancellation_token
91
+ @deadline = deadline
92
+ @tracer_span = tracer_span
93
+ @token_budget = token_budget
94
+ @max_parallel_tools = max_parallel_tools
95
+ @approval_policy = approval_policy
96
+ @redaction_policy = redaction_policy
97
+ @provider_limits = provider_limits
98
+ @task_id = task_id
99
+ @parent_task_id = parent_task_id
100
+ end
101
+
102
+ # Returns a new +InvocationContext+ with the given attributes merged in.
103
+ # All other attributes are carried over unchanged.
104
+ #
105
+ # @param overrides [Hash] keyword arguments to override
106
+ # @return [InvocationContext]
107
+ # @api private
108
+ def merge(**overrides)
109
+ InvocationContext.new(
110
+ thread_id: overrides.fetch(:thread_id, @thread_id),
111
+ session_id: overrides.fetch(:session_id, @session_id),
112
+ user_id: overrides.fetch(:user_id, @user_id),
113
+ cancellation_token: overrides.fetch(:cancellation_token, @cancellation_token),
114
+ deadline: overrides.fetch(:deadline, @deadline),
115
+ tracer_span: overrides.fetch(:tracer_span, @tracer_span),
116
+ token_budget: overrides.fetch(:token_budget, @token_budget),
117
+ max_parallel_tools: overrides.fetch(:max_parallel_tools, @max_parallel_tools),
118
+ approval_policy: overrides.fetch(:approval_policy, @approval_policy),
119
+ redaction_policy: overrides.fetch(:redaction_policy, @redaction_policy),
120
+ provider_limits: overrides.fetch(:provider_limits, @provider_limits),
121
+ task_id: overrides.fetch(:task_id, @task_id),
122
+ parent_task_id: overrides.fetch(:parent_task_id, @parent_task_id)
123
+ )
124
+ end
125
+
126
+ # Convenience: returns the cancellation token or a new never-cancelled token.
127
+ # @return [CancellationToken]
128
+ # @api private
129
+ def effective_cancellation_token
130
+ @cancellation_token || CancellationToken.new
131
+ end
132
+
133
+ # Returns the cancellation token to use for an invocation, taking both the
134
+ # explicit +cancellation_token+ and the +deadline+ into account.
135
+ #
136
+ # - When +cancellation_token+ is set, it is returned unchanged.
137
+ # - When only +deadline+ is set, a new {CancellationToken} is created and
138
+ # the deadline is attached to it via {Deadline#attach_to}.
139
+ # - When neither is set, returns +nil+.
140
+ #
141
+ # @return [CancellationToken, nil]
142
+ # @api private
143
+ def effective_timeout_token
144
+ return @cancellation_token if @cancellation_token
145
+ return nil if @deadline.nil?
146
+
147
+ token = CancellationToken.new
148
+ @deadline.attach_to(token)
149
+ token
150
+ end
151
+ end
152
+ end
@@ -11,12 +11,33 @@ module Phronomy
11
11
  class Base
12
12
  # Retrieve knowledge chunks relevant to the given query.
13
13
  #
14
- # @param query [String, nil] the current user input used to select relevant chunks
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
15
16
  # @return [Array<Hash>] array of { content: String, type: Symbol }
16
- def fetch(query: nil)
17
+ # @api public
18
+ def fetch(query: nil, cancellation_token: nil)
19
+ cancellation_token&.raise_if_cancelled!
17
20
  raise NotImplementedError, "#{self.class}#fetch is not implemented"
18
21
  end
19
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
+
20
41
  # Returns true when this source's content is considered static (i.e. does
21
42
  # not change between agent invocations). Static sources are eligible for
22
43
  # fingerprint-based caching in ContextVersionCache.
@@ -24,6 +45,7 @@ module Phronomy
24
45
  # Override in subclasses that return fixed content.
25
46
  #
26
47
  # @return [Boolean]
48
+ # @api public
27
49
  def static?
28
50
  false
29
51
  end
@@ -43,6 +43,7 @@ module Phronomy
43
43
  # Call this after saving a new set of messages (e.g. from a ConversationManager save hook).
44
44
  #
45
45
  # @param messages [Array] message objects responding to #role and #content
46
+ # @api public
46
47
  def update(messages:)
47
48
  messages.each do |msg|
48
49
  next unless msg.role.to_sym == :user
@@ -54,9 +55,12 @@ module Phronomy
54
55
  # Returns a single chunk containing all known entity facts in XML context format.
55
56
  # Returns an empty array when no entities have been discovered.
56
57
  #
57
- # @param query [String, nil] unused — entity knowledge is always fully injected
58
+ # @param query [String, nil] unused — entity knowledge is always fully injected
59
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
58
60
  # @return [Array<Hash>]
59
- def fetch(query: nil)
61
+ # @api public
62
+ def fetch(query: nil, cancellation_token: nil)
63
+ cancellation_token&.raise_if_cancelled!
60
64
  return [] if @entities.empty?
61
65
 
62
66
  lines = @entities.map { |key, value| "- #{key}: #{value}" }.join("\n")
@@ -70,6 +74,7 @@ module Phronomy
70
74
  # Returns the current entity store (primarily for testing).
71
75
  #
72
76
  # @return [Hash]
77
+ # @api public
73
78
  def entities
74
79
  @entities.dup
75
80
  end
@@ -22,6 +22,7 @@ module Phronomy
22
22
  # @param type [Symbol] semantic tag (default :rag)
23
23
  # @param source [String, nil] default source label; falls back to
24
24
  # each document's :source metadata when nil
25
+ # @api public
25
26
  def initialize(store:, embeddings:, k: 5, type: :rag, source: nil)
26
27
  @store = store
27
28
  @embeddings = embeddings
@@ -34,13 +35,16 @@ module Phronomy
34
35
  #
35
36
  # Returns an empty array when query is nil or blank.
36
37
  #
37
- # @param query [String, nil]
38
+ # @param query [String, nil]
39
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
38
40
  # @return [Array<Hash>]
39
- def fetch(query: nil)
41
+ # @api public
42
+ def fetch(query: nil, cancellation_token: nil)
43
+ cancellation_token&.raise_if_cancelled!
40
44
  return [] if query.nil? || query.strip.empty?
41
45
 
42
- vector = @embeddings.embed(query)
43
- results = @store.search(query_embedding: vector, k: @k)
46
+ vector = @embeddings.embed(query, cancellation_token)
47
+ results = @store.search(query_embedding: vector, k: @k, cancellation_token: cancellation_token)
44
48
  results.map do |doc|
45
49
  chunk = {content: doc[:metadata][:content], type: @type}
46
50
  src = @source || doc[:metadata][:source]
@@ -19,6 +19,7 @@ module Phronomy
19
19
  # @param source [String, nil] label identifying where this knowledge came from
20
20
  # (e.g. a filename). Included in the context XML tag and exposed to the LLM
21
21
  # so that agents can produce grounded citations.
22
+ # @api public
22
23
  def initialize(text, type: :static, source: nil)
23
24
  @text = text.to_s
24
25
  @type = type
@@ -27,9 +28,12 @@ module Phronomy
27
28
 
28
29
  # Returns the fixed text as a single chunk, regardless of query.
29
30
  #
30
- # @param query [String, nil] ignored for static knowledge
31
+ # @param query [String, nil] ignored for static knowledge
32
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
31
33
  # @return [Array<Hash>]
32
- def fetch(query: nil)
34
+ # @api public
35
+ def fetch(query: nil, cancellation_token: nil)
36
+ cancellation_token&.raise_if_cancelled!
33
37
  return [] if @text.empty?
34
38
 
35
39
  chunk = {content: @text, type: @type}
@@ -39,6 +43,7 @@ module Phronomy
39
43
 
40
44
  # Static knowledge content never changes between invocations.
41
45
  # @return [true]
46
+ # @api public
42
47
  def static?
43
48
  true
44
49
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module LLMAdapter
5
+ # Abstract base class for LLM adapters.
6
+ #
7
+ # Subclasses must implement {#complete} and {#stream}.
8
+ # The agent pipeline calls {#complete_async} / {#stream_async} which wrap
9
+ # those methods in a {BlockingAdapterPool} submission.
10
+ class Base
11
+ # Performs a blocking (non-streaming) LLM completion.
12
+ # Implementors must call +chat.ask(message)+ (or equivalent) and
13
+ # return the response object.
14
+ #
15
+ # @param chat [Object] the configured chat session object
16
+ # @param message [String] the user message
17
+ # @param config [Hash] the invocation config (e.g. +:cancellation_token+)
18
+ # @return [Object] LLM response object
19
+ # @raise [NotImplementedError]
20
+ # @api private
21
+ def complete(chat, message, config: {})
22
+ raise NotImplementedError, "#{self.class}#complete is not implemented"
23
+ end
24
+
25
+ # Performs a blocking streaming LLM completion.
26
+ # Implementors must call +chat.ask(message) { |chunk| block.call(chunk) }+
27
+ # (or equivalent) and return the response object.
28
+ #
29
+ # @param chat [Object] the configured chat session object
30
+ # @param message [String] the user message
31
+ # @param config [Hash] the invocation config
32
+ # @yield [chunk] streaming chunk from the LLM
33
+ # @return [Object] LLM response object
34
+ # @raise [NotImplementedError]
35
+ # @api private
36
+ def stream(chat, message, config: {}, &block)
37
+ raise NotImplementedError, "#{self.class}#stream is not implemented"
38
+ end
39
+
40
+ # Submits a non-streaming LLM call to {BlockingAdapterPool} and returns
41
+ # a {BlockingAdapterPool::PendingOperation}.
42
+ #
43
+ # @param chat [Object] configured chat session
44
+ # @param message [String] user message
45
+ # @param config [Hash] invocation config
46
+ # @param pool [BlockingAdapterPool] pool to submit to
47
+ # @return [BlockingAdapterPool::PendingOperation]
48
+ # @api private
49
+ def complete_async(chat, message, config: {}, pool: default_pool)
50
+ token = config[:cancellation_token]
51
+ timeout = config[:llm_timeout]
52
+ pool.submit(timeout: timeout, cancellation_token: token) do
53
+ complete(chat, message, config: config)
54
+ end
55
+ end
56
+
57
+ # Submits a streaming LLM call to {BlockingAdapterPool} and returns
58
+ # a {BlockingAdapterPool::PendingOperation}.
59
+ #
60
+ # When +enqueue_to:+ is given, streaming chunks are pushed into that
61
+ # {AsyncQueue} from the worker thread instead of being passed directly
62
+ # to the caller's block. The queue is closed (via +ensure+) after the
63
+ # LLM call finishes so the consumer's drain loop terminates naturally.
64
+ # This keeps user-supplied blocks off the blocking-pool worker thread.
65
+ #
66
+ # When +enqueue_to:+ is nil and a block is given, the block is invoked
67
+ # directly from the worker thread (legacy behaviour, preserved for
68
+ # backward compatibility).
69
+ #
70
+ # @param chat [Object] configured chat session
71
+ # @param message [String] user message
72
+ # @param config [Hash] invocation config
73
+ # @param pool [BlockingAdapterPool] pool to submit to
74
+ # @param enqueue_to [AsyncQueue, nil] when set, push chunks here instead of
75
+ # calling the block on the worker thread
76
+ # @yield [chunk] streaming chunk — only used when +enqueue_to:+ is nil
77
+ # @return [BlockingAdapterPool::PendingOperation]
78
+ # @api private
79
+ def stream_async(chat, message, config: {}, pool: default_pool, enqueue_to: nil, &block)
80
+ token = config[:cancellation_token]
81
+ timeout = config[:llm_timeout]
82
+ if enqueue_to
83
+ pool.submit(timeout: timeout, cancellation_token: token) do
84
+ stream(chat, message, config: config) do |chunk|
85
+ enqueue_to.push(chunk)
86
+ end
87
+ ensure
88
+ enqueue_to.close
89
+ end
90
+ else
91
+ pool.submit(timeout: timeout, cancellation_token: token) do
92
+ stream(chat, message, config: config, &block)
93
+ end
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def default_pool
100
+ Phronomy::Runtime.instance.blocking_io
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module LLMAdapter
5
+ # LLM adapter that delegates to the RubyLLM blocking client.
6
+ #
7
+ # This is the default adapter used by Phronomy agents. It wraps
8
+ # +chat.ask+ (and its streaming variant) so that the blocking HTTP
9
+ # call runs inside {BlockingAdapterPool} rather than on the EventLoop
10
+ # thread or the caller's thread directly.
11
+ #
12
+ # @example Explicitly configuring this adapter
13
+ # Phronomy.configure do |c|
14
+ # c.llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
15
+ # end
16
+ class RubyLLM < Base
17
+ # Delegates to +chat.ask(message)+.
18
+ #
19
+ # @param chat [Object] RubyLLM chat session
20
+ # @param message [String] user message
21
+ # @param config [Hash] invocation config (not used directly by this impl)
22
+ # @return [Object] RubyLLM response
23
+ # @api private
24
+ def complete(chat, message, config: {})
25
+ chat.ask(message)
26
+ end
27
+
28
+ # Delegates to +chat.ask(message) { |chunk| block.call(chunk) }+.
29
+ #
30
+ # @param chat [Object] RubyLLM chat session
31
+ # @param message [String] user message
32
+ # @param config [Hash] invocation config
33
+ # @yield [chunk] streaming chunk forwarded from +chat.ask+
34
+ # @return [Object] RubyLLM response
35
+ # @api private
36
+ def stream(chat, message, config: {}, &block)
37
+ chat.ask(message, &block)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Namespace for LLM adapter implementations.
5
+ #
6
+ # An LLMAdapter decouples Phronomy's agent pipeline from direct
7
+ # dependency on the RubyLLM blocking client. All LLM calls in
8
+ # {Agent::Base} are routed through the adapter so that:
9
+ #
10
+ # - Blocking HTTP can be submitted to {BlockingAdapterPool} for bounded
11
+ # concurrency and per-operation timeouts.
12
+ # - Alternative LLM clients can be swapped in without changing agent code.
13
+ #
14
+ # @example Configuring a custom adapter
15
+ # Phronomy.configure do |c|
16
+ # c.llm_adapter = MyCustomAdapter.new
17
+ # end
18
+ module LLMAdapter
19
+ end
20
+ end
@@ -16,6 +16,7 @@ module Phronomy
16
16
  # @param source [String] file path, URL, or other source identifier
17
17
  # @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
18
18
  # @raise [NotImplementedError] when not overridden by a subclass
19
+ # @api public
19
20
  def load(source)
20
21
  raise NotImplementedError, "#{self.class}#load is not implemented"
21
22
  end
@@ -20,6 +20,7 @@ module Phronomy
20
20
  class CsvLoader < Base
21
21
  # @param headers [Boolean] treat the first row as headers (default: true)
22
22
  # @param text_column [String, nil] if set, use only this column as the document text
23
+ # @api public
23
24
  def initialize(headers: true, text_column: nil)
24
25
  @headers = headers
25
26
  @text_column = text_column
@@ -28,6 +29,7 @@ module Phronomy
28
29
  # @param source [String] path to a CSV file
29
30
  # @return [Array<Hash>]
30
31
  # @raise [Errno::ENOENT] if the file does not exist
32
+ # @api public
31
33
  def load(source)
32
34
  rows = CSV.read(source, headers: @headers, encoding: "UTF-8")
33
35
 
@@ -24,6 +24,7 @@ module Phronomy
24
24
  HEADING_RE = /^(\#{1,6})\s+(.+)$/
25
25
 
26
26
  # @param split_on_headings [Boolean] split on H1–H6 boundaries (default: true)
27
+ # @api public
27
28
  def initialize(split_on_headings: true)
28
29
  @split_on_headings = split_on_headings
29
30
  end
@@ -31,6 +32,7 @@ module Phronomy
31
32
  # @param source [String] path to a Markdown file
32
33
  # @return [Array<Hash>]
33
34
  # @raise [Errno::ENOENT] if the file does not exist
35
+ # @api public
34
36
  def load(source)
35
37
  content = File.read(source, encoding: "UTF-8")
36
38
  return [{text: content, metadata: {source: source}}] unless @split_on_headings
@@ -12,6 +12,7 @@ module Phronomy
12
12
  # @param source [String] absolute or relative path to a text file
13
13
  # @return [Array<Hash>] single-element array with the file contents
14
14
  # @raise [Errno::ENOENT] if the file does not exist
15
+ # @api public
15
16
  def load(source)
16
17
  text = File.read(source, encoding: "UTF-8")
17
18
  [{text: text, metadata: {source: source}}]
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Task-centric observability snapshot (Issue #276, extended in #307).
5
+ #
6
+ # Collects live metrics from the shared Runtime components
7
+ # (BlockingAdapterPool, EventLoop, and Runtime task registry) and returns
8
+ # them as a plain Hash so they can be forwarded to any monitoring backend
9
+ # (Prometheus, OpenTelemetry, StatsD, etc.).
10
+ #
11
+ # All metrics are read at the moment {.snapshot} is called; no
12
+ # persistent state is held here.
13
+ #
14
+ # @example Exporting to a metrics endpoint
15
+ # data = Phronomy::Metrics.snapshot
16
+ # # => { blocking_pool_active: 2, active_agent_tasks: 1, ... }
17
+ module Metrics
18
+ # Returns a Hash of current observability metrics.
19
+ #
20
+ # @return [Hash{Symbol => Numeric}]
21
+ # @api public
22
+ def self.snapshot
23
+ pool = Runtime.instance.blocking_io
24
+ el = EventLoop.instance
25
+ task_snap = Runtime.instance.task_snapshot
26
+
27
+ {
28
+ blocking_pool_active: pool.active_count,
29
+ blocking_pool_queue_length: pool.queue_depth,
30
+ blocking_pool_abandoned_total: pool.abandoned_count,
31
+ blocking_pool_size: pool.pool_size,
32
+ event_loop_lag_last_ms: (el.last_lag_seconds * 1000).round(3),
33
+ event_loop_lag_max_ms: (el.max_lag_seconds * 1000).round(3),
34
+ event_loop_lag_average_ms: (el.average_lag_seconds * 1000).round(3)
35
+ }.merge(task_snap)
36
+ end
37
+ end
38
+ end
@@ -9,6 +9,7 @@ module Phronomy
9
9
 
10
10
  # @param input [String, #to_s] text to parse
11
11
  # @return [Object] parsed result
12
+ # @api public
12
13
  def invoke(input, config: {})
13
14
  parse(input.is_a?(String) ? input : input.to_s)
14
15
  end
@@ -10,6 +10,7 @@ module Phronomy
10
10
  # @param text [String]
11
11
  # @return [Hash, Array] result parsed with symbolize_names: true
12
12
  # @raise [Phronomy::ParseError] raised when JSON parsing fails
13
+ # @api public
13
14
  def parse(text)
14
15
  json_str = extract_json(text)
15
16
  JSON.parse(json_str, symbolize_names: true)
@@ -19,10 +20,28 @@ module Phronomy
19
20
 
20
21
  private
21
22
 
22
- # Extracts the inner content of a Markdown code fence if present;
23
- # otherwise returns the text as-is.
23
+ # Extracts a JSON string from the LLM response text.
24
+ #
25
+ # Strategy (in order):
26
+ # 1. Try each ```json ... ``` or ``` ... ``` code fence in document order,
27
+ # returning the content of the first one that parses as valid JSON.
28
+ # 2. Try the raw text stripped of leading/trailing whitespace.
29
+ #
30
+ # This handles:
31
+ # - Single JSON code fence (common case)
32
+ # - Multiple code fences — the first parseable JSON block wins
33
+ # - No fence — LLM omitted the backticks but returned valid JSON
24
34
  def extract_json(text)
25
- text.match(/```(?:json)?\s*\n?(.*?)\n?```/m)&.captures&.first || text.strip
35
+ text.scan(/```(?:json)?\s*\n?(.*?)\n?```/m).each do |captures|
36
+ candidate = captures.first.strip
37
+ JSON.parse(candidate)
38
+ return candidate
39
+ rescue JSON::ParserError
40
+ next
41
+ end
42
+
43
+ # Fallback: no valid fence found — try the raw text
44
+ text.strip
26
45
  end
27
46
  end
28
47
  end
@@ -9,6 +9,7 @@ module Phronomy
9
9
  # parser.parse('{"name":"Alice","age":30}') #=> #<struct PersonSchema name="Alice", age=30>
10
10
  class StructuredParser < Base
11
11
  # @param schema_class [Class] Struct with keyword_init: true or equivalent
12
+ # @api public
12
13
  def initialize(schema_class)
13
14
  @schema_class = schema_class
14
15
  end
@@ -16,6 +17,7 @@ module Phronomy
16
17
  # @param text [String]
17
18
  # @return [Object] instance of schema_class
18
19
  # @raise [Phronomy::ParseError] raised when JSON parsing or schema instantiation fails
20
+ # @api public
19
21
  def parse(text)
20
22
  data = JsonParser.new.parse(text)
21
23
  @schema_class.new(**data)
@@ -27,6 +27,7 @@ module Phronomy
27
27
 
28
28
  # @param template [String] human message template with {{var}} placeholders
29
29
  # @param system_template [String, nil] optional system message template
30
+ # @api public
30
31
  def initialize(template:, system_template: nil)
31
32
  @template = template
32
33
  @system_template = system_template
@@ -36,6 +37,7 @@ module Phronomy
36
37
  #
37
38
  # @param variables [Hash{Symbol => String}]
38
39
  # @return [String]
40
+ # @api public
39
41
  def format(**variables)
40
42
  substitute(@template, variables)
41
43
  end
@@ -45,6 +47,7 @@ module Phronomy
45
47
  #
46
48
  # @param variables [Hash{Symbol => String}]
47
49
  # @return [String, nil]
50
+ # @api public
48
51
  def format_system(**variables)
49
52
  @system_template && substitute(@system_template, variables)
50
53
  end
@@ -54,6 +57,7 @@ module Phronomy
54
57
  #
55
58
  # @param input [Hash{Symbol => String}]
56
59
  # @return [Hash]
60
+ # @api public
57
61
  def invoke(input, config: {})
58
62
  vars = normalize_input(input)
59
63
  result = {prompt: format(**vars)}
@@ -65,6 +69,7 @@ module Phronomy
65
69
  # Returns the list of placeholder names found in both templates.
66
70
  #
67
71
  # @return [Array<Symbol>]
72
+ # @api public
68
73
  def variables
69
74
  names = @template.scan(PLACEHOLDER).flatten
70
75
  names += @system_template.scan(PLACEHOLDER).flatten if @system_template