phronomy 0.1.4 → 0.2.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/CHANGELOG.md +56 -0
- data/README.md +49 -38
- data/docs/trustworthy_ai_enhancements.md +4 -4
- data/lib/phronomy/actor.rb +68 -0
- data/lib/phronomy/agent/base.rb +80 -52
- data/lib/phronomy/context/context_version_cache.rb +10 -33
- data/lib/phronomy/memory/conversation_manager.rb +9 -38
- data/lib/phronomy/memory/retrieval/semantic.rb +7 -7
- data/lib/phronomy/memory/storage/active_record.rb +20 -0
- data/lib/phronomy/memory/storage/base.rb +22 -0
- data/lib/phronomy/memory/storage/in_memory.rb +65 -26
- data/lib/phronomy/state_store/active_record.rb +1 -1
- data/lib/phronomy/state_store/base.rb +14 -16
- data/lib/phronomy/state_store/in_memory.rb +23 -10
- data/lib/phronomy/state_store/redis.rb +1 -1
- data/lib/phronomy/thread_actor_registry.rb +52 -0
- data/lib/phronomy/tool/base.rb +1 -1
- data/lib/phronomy/tool/mcp_tool.rb +10 -9
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/trust_pipeline.rb +41 -49
- data/lib/phronomy/vector_store/in_memory.rb +5 -7
- data/lib/phronomy/vector_store/redis_search.rb +4 -6
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +281 -0
- data/lib/phronomy/workflow_context.rb +119 -0
- data/lib/phronomy/workflow_runner.rb +262 -0
- data/lib/phronomy.rb +30 -34
- metadata +25 -10
- data/lib/phronomy/graph/compiled_graph.rb +0 -191
- data/lib/phronomy/graph/parallel_node.rb +0 -193
- data/lib/phronomy/graph/state.rb +0 -105
- data/lib/phronomy/graph/state_graph.rb +0 -149
- data/lib/phronomy/graph.rb +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0aaadfedad1ee8b4afa1efb2e205a626a20cd636f268e711dce29128c84b6fe
|
|
4
|
+
data.tar.gz: f781f66ae3d570caca771d2b5579874169acef5eeed7f6cf99474a4c82fd077a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 73793efee9cf2c0cb81828fc86927181edca2b4d3fa830a6cefa176b30fd70c57c75e71173d57b51d385377ec3795f1d8cb42e25c3650be40eff543fc2f856fd
|
|
7
|
+
data.tar.gz: b7dd9cc538e478774d46cf7823e4f5e52b599a02fdd1edf300439287dabfe2fc9a90e93f50647fffa246f7f3c90bd4abf6cf258e9723af1222f836d89125dc59
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`Phronomy::Graph::Context`** module — canonical module for defining workflow
|
|
15
|
+
context classes (replaces the removed `Phronomy::Graph::State`).
|
|
16
|
+
- **`Phronomy::Graph.register_context_class`** — registers context classes for
|
|
17
|
+
deserialization from external stores (Redis, DB).
|
|
18
|
+
- **`Phronomy::Workflow.define`** DSL — primary high-level API for declaring
|
|
19
|
+
stateful workflows (`state`, `wait_state`, `event`, `after`, `initial`).
|
|
20
|
+
- **`Phronomy::Graph::WorkflowRunner`** — state-machine execution engine backing
|
|
21
|
+
the Workflow DSL. Replaces the removed `CompiledGraph`.
|
|
22
|
+
- **`app.send_event(event, config:)`** — event-driven resume for workflows halted
|
|
23
|
+
at a `wait_state`.
|
|
24
|
+
- **`state.halted?`** — returns `true` when the workflow is paused at a `wait_state`.
|
|
25
|
+
- **`state.phase`** — single source of truth for execution state.
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
29
|
+
- `Phronomy::Graph::StateGraph` / `CompiledGraph` — use `Phronomy::Workflow.define`.
|
|
30
|
+
- `Phronomy::Graph::State` — use `Phronomy::Graph::Context`.
|
|
31
|
+
- `Phronomy::Graph.register_state_class` — use `register_context_class`.
|
|
32
|
+
- `state.current_nodes` / `state.halted_before` — use `state.phase` / `state.halted?`.
|
|
33
|
+
- `compiled.interrupt_before` / `compiled.interrupt_after` — use `wait_state` + `event`.
|
|
34
|
+
- `compiled.resume` — use `app.send_event`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## [0.2.0] - 2026-05-13
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- `Phronomy::Graph::WorkflowRunner` — state_machines-based execution engine
|
|
43
|
+
(introduced as the internal successor to `CompiledGraph`).
|
|
44
|
+
- `state.phase` — single source of truth for graph execution state (replaces
|
|
45
|
+
`current_nodes` + `halted_before` dual attributes).
|
|
46
|
+
- `state.halted?` — returns `true` when the graph is paused.
|
|
47
|
+
- `CompiledGraph#add_wait_state` — declared a named wait state that halts
|
|
48
|
+
automatically when reached (later superseded by `wait_state` DSL in `Workflow.define`).
|
|
49
|
+
- `CompiledGraph#send_event(state:, event:, input: nil)` — event-driven resume API
|
|
50
|
+
(later superseded by `app.send_event`).
|
|
51
|
+
|
|
52
|
+
### Removed
|
|
53
|
+
|
|
54
|
+
- `ParallelNode` and `add_parallel_node` DSL. Use `Thread.new` or
|
|
55
|
+
`Concurrent::Future` at the application level instead.
|
|
56
|
+
- `Phronomy::Graph::TimeoutError` (was only used by `ParallelNode`).
|
data/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Phronomy
|
|
2
2
|
|
|
3
3
|
**Phronomy** is a Ruby AI agent framework inspired by open-source AI agent frameworks.
|
|
4
|
-
It provides composable building blocks —
|
|
4
|
+
It provides composable building blocks — Workflows, Agents, and Memory — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
|
|
5
5
|
|
|
6
6
|
## Features
|
|
7
7
|
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
8
|
+
- **Workflow** — Build stateful, branching agent workflows with wait_state/send_event support
|
|
9
|
+
- **Workflow Parallel Node** — Execute independent workflow branches concurrently using application-level Ruby threads
|
|
10
10
|
- **Agent** — ReAct-style tool-calling agents with memory and guardrails
|
|
11
11
|
- **Before-Completion Hook** — Three-tier (global / class / instance) LLM parameter injection before each chat request
|
|
12
12
|
- **Memory** — Window, summary, ActiveRecord-backed, semantic, and composite conversation memory
|
|
@@ -70,31 +70,39 @@ result = ResearchAgent.new.invoke("What happened in AI research this week?")
|
|
|
70
70
|
puts result[:output]
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
###
|
|
73
|
+
### Workflow — Stateful workflow with wait_state/send_event
|
|
74
74
|
|
|
75
75
|
```ruby
|
|
76
|
-
class
|
|
77
|
-
include Phronomy::
|
|
76
|
+
class ReviewContext
|
|
77
|
+
include Phronomy::WorkflowContext
|
|
78
78
|
field :draft, type: :replace
|
|
79
79
|
field :feedback, type: :replace
|
|
80
80
|
field :approved, type: :replace, default: false
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
app = Phronomy::Workflow.define(ReviewContext) do
|
|
84
|
+
initial :write
|
|
85
|
+
state :write, action: ->(s) { s.merge(draft: Writer.call(s)) }
|
|
86
|
+
state :review, action: ->(s) { s.merge(feedback: Reviewer.call(s.draft)) }
|
|
87
|
+
wait_state :awaiting_approval # halts here for human decision
|
|
88
|
+
state :finalize, action: ->(s) { s.merge(approved: true) }
|
|
89
|
+
after :write, to: :review
|
|
90
|
+
after :review, to: :awaiting_approval
|
|
91
|
+
after :finalize, to: :__finish__
|
|
92
|
+
event :approve, from: :awaiting_approval, to: :finalize
|
|
93
|
+
event :reject, from: :awaiting_approval, to: :write
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
Phronomy.configure { |c| c.default_state_store = Phronomy::StateStore::InMemory.new }
|
|
97
|
+
|
|
98
|
+
# First run — halts at :awaiting_approval
|
|
99
|
+
state = app.invoke({ draft: "" }, config: { thread_id: "doc-1" })
|
|
100
|
+
puts "Halted: #{state.halted?}" # => true
|
|
101
|
+
puts "Draft: #{state.draft}"
|
|
102
|
+
|
|
103
|
+
# Resume after human approval
|
|
104
|
+
final = app.send_event(:approve, config: { thread_id: "doc-1" })
|
|
105
|
+
puts "Approved: #{final.approved}" # => true
|
|
98
106
|
```
|
|
99
107
|
|
|
100
108
|
### Multi-Agent — Agent-as-Tool pattern
|
|
@@ -238,29 +246,32 @@ result.citations.each do |c|
|
|
|
238
246
|
end
|
|
239
247
|
```
|
|
240
248
|
|
|
241
|
-
###
|
|
249
|
+
### Workflow Parallel Node — Concurrent branches
|
|
250
|
+
|
|
251
|
+
Phronomy does not provide a built-in parallel abstraction. Use application-level Ruby threads inside a `state` action:
|
|
242
252
|
|
|
243
253
|
```ruby
|
|
244
|
-
class
|
|
245
|
-
include Phronomy::
|
|
254
|
+
class EnrichContext
|
|
255
|
+
include Phronomy::WorkflowContext
|
|
246
256
|
field :summary, type: :replace
|
|
247
|
-
field :tags, type: :append,
|
|
257
|
+
field :tags, type: :append, default: -> { [] }
|
|
248
258
|
end
|
|
249
259
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
)
|
|
260
|
+
app = Phronomy::Workflow.define(EnrichContext) do
|
|
261
|
+
initial :enrich
|
|
262
|
+
state :enrich, action: ->(s) do
|
|
263
|
+
results = {}
|
|
264
|
+
threads = [
|
|
265
|
+
Thread.new { results[:summary] = Summarizer.call(s) },
|
|
266
|
+
Thread.new { results[:tags] = Tagger.call(s) }
|
|
267
|
+
]
|
|
268
|
+
threads.each { |t| t.join(10) } # 10-second timeout
|
|
269
|
+
s.merge(summary: results[:summary], tags: Array(results[:tags]))
|
|
270
|
+
end
|
|
271
|
+
after :enrich, to: :__finish__
|
|
272
|
+
end
|
|
259
273
|
|
|
260
|
-
|
|
261
|
-
graph.add_edge(:enrich, Phronomy::Graph::StateGraph::FINISH)
|
|
262
|
-
app = graph.compile
|
|
263
|
-
app.invoke({}, config: { thread_id: "t1" })
|
|
274
|
+
state = app.invoke({}, config: { thread_id: "t1" })
|
|
264
275
|
```
|
|
265
276
|
|
|
266
277
|
### Output Parser — Structured LLM responses
|
|
@@ -483,8 +494,8 @@ bundle exec ruby NN_example_name/run.rb
|
|
|
483
494
|
|---|-----------|----------------------|
|
|
484
495
|
| 01 | `01_basic_chain/` | PromptTemplate → LLMChain pipeline |
|
|
485
496
|
| 02 | `02_react_agent/` | ReAct tool-calling agent |
|
|
486
|
-
| 03 | `03_state_graph/` | Stateful
|
|
487
|
-
| 04 | `04_interrupt_resume/` | Human-in-the-loop
|
|
497
|
+
| 03 | `03_state_graph/` | Stateful workflow with wait_state/send_event |
|
|
498
|
+
| 04 | `04_interrupt_resume/` | Human-in-the-loop wait_state and resume |
|
|
488
499
|
| 05 | `05_multi_agent/` | Multi-agent coordination via Agent-as-Tool |
|
|
489
500
|
| 06 | `06_guardrails/` | Input/output guardrails |
|
|
490
501
|
| 07 | `07_tracing/` | Custom observability with Langfuse tracer |
|
|
@@ -49,7 +49,7 @@ cannot be delegated to the LLM must be enforced by phronomy or the application l
|
|
|
49
49
|
| Layer | Responsibility | Status |
|
|
50
50
|
|---|---|---|
|
|
51
51
|
| LLM | Basic harmful-content avoidance (RLHF) | Model-dependent, not guaranteed |
|
|
52
|
-
| **phronomy** | Intervention points, iteration limits, approval gates | ✅ `
|
|
52
|
+
| **phronomy** | Intervention points, iteration limits, approval gates | ✅ `wait_state`/`send_event`, `requires_approval`, `max_iterations` — see `lib/phronomy/workflow.rb`, `lib/phronomy/agent/base.rb` |
|
|
53
53
|
| **phronomy** | Built-in guardrails (PII, prompt injection) | ❌ Not implemented — **planned (Feature A)** |
|
|
54
54
|
| Application | Concrete guardrail logic, approval workflows | Application responsibility |
|
|
55
55
|
|
|
@@ -82,7 +82,7 @@ cannot be delegated to the LLM must be enforced by phronomy or the application l
|
|
|
82
82
|
| Layer | Responsibility | Status |
|
|
83
83
|
|---|---|---|
|
|
84
84
|
| LLM | Chain-of-thought generation | Prompt-dependent |
|
|
85
|
-
| **phronomy** | Processing step recording via Graph and Tracing | ✅ Partial — `
|
|
85
|
+
| **phronomy** | Processing step recording via Graph and Tracing | ✅ Partial — `Workflow`/`WorkflowRunner`, `Tracing` |
|
|
86
86
|
| Application | Explanation UI, CoT prompt design | Application responsibility |
|
|
87
87
|
|
|
88
88
|
**Planned work:** None in this iteration.
|
|
@@ -184,8 +184,8 @@ attributed to users or sessions, which is a requirement for accountability under
|
|
|
184
184
|
NIST AI RMF 3.4.
|
|
185
185
|
|
|
186
186
|
**Design:**
|
|
187
|
-
- `Agent::Base#invoke` and `
|
|
188
|
-
`lib/phronomy/agent/base.rb
|
|
187
|
+
- `Agent::Base#invoke` and `WorkflowRunner#invoke` already accept `config: {}` — see
|
|
188
|
+
`lib/phronomy/agent/base.rb` and `lib/phronomy/graph/workflow_runner.rb`.
|
|
189
189
|
- Add two new optional keys to `config:`:
|
|
190
190
|
- `user_id:` (String | nil) — caller identity
|
|
191
191
|
- `session_id:` (String | nil) — session / request identity
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Lightweight synchronous actor backed by a dedicated +Thread+ and a +Queue+.
|
|
5
|
+
#
|
|
6
|
+
# A caller submits work via {#call}, which blocks until the actor's thread
|
|
7
|
+
# finishes executing the block and then returns the result (or re-raises any
|
|
8
|
+
# exception that occurred inside the actor).
|
|
9
|
+
#
|
|
10
|
+
# === Reentrant safety
|
|
11
|
+
#
|
|
12
|
+
# If {#call} is invoked from within the actor's own thread (i.e. from inside
|
|
13
|
+
# a block that is already executing on this actor), the block is executed
|
|
14
|
+
# directly in the current thread instead of being pushed onto the queue.
|
|
15
|
+
# This prevents deadlocks in deeply nested call paths without requiring
|
|
16
|
+
# callers to track whether they are already "inside" the actor.
|
|
17
|
+
#
|
|
18
|
+
# === Usage
|
|
19
|
+
#
|
|
20
|
+
# actor = Phronomy::Actor.new
|
|
21
|
+
# result = actor.call { expensive_operation() } # blocks caller; runs on actor thread
|
|
22
|
+
# actor.stop # graceful shutdown
|
|
23
|
+
class Actor
|
|
24
|
+
def initialize
|
|
25
|
+
@queue = Queue.new
|
|
26
|
+
@thread = Thread.new do
|
|
27
|
+
loop do
|
|
28
|
+
task = @queue.pop
|
|
29
|
+
break if task == :stop
|
|
30
|
+
task.call
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Run +block+ on the actor's thread and return its result.
|
|
36
|
+
#
|
|
37
|
+
# If the current thread is already the actor's thread (reentrant call),
|
|
38
|
+
# the block is executed inline to prevent deadlocks.
|
|
39
|
+
#
|
|
40
|
+
# Any exception raised inside the block is captured and re-raised in the
|
|
41
|
+
# calling thread.
|
|
42
|
+
#
|
|
43
|
+
# @yield block to execute on the actor's thread
|
|
44
|
+
# @return the return value of the block
|
|
45
|
+
def call(&block)
|
|
46
|
+
return block.call if Thread.current == @thread
|
|
47
|
+
|
|
48
|
+
done = Queue.new
|
|
49
|
+
@queue.push(-> {
|
|
50
|
+
begin
|
|
51
|
+
done.push([true, block.call])
|
|
52
|
+
rescue => e
|
|
53
|
+
done.push([false, e])
|
|
54
|
+
end
|
|
55
|
+
})
|
|
56
|
+
success, value = done.pop
|
|
57
|
+
raise value unless success
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Send a +:stop+ sentinel to gracefully terminate the actor's thread.
|
|
62
|
+
# Pending tasks already in the queue will still be processed before
|
|
63
|
+
# the thread exits.
|
|
64
|
+
def stop
|
|
65
|
+
@queue.push(:stop)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -412,21 +412,8 @@ module Phronomy
|
|
|
412
412
|
# result = MyAgent.new.invoke("What is Ruby?")
|
|
413
413
|
# puts result[:output]
|
|
414
414
|
def invoke(input, config: {})
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
begin
|
|
418
|
-
invoke_once(input, config: config)
|
|
419
|
-
rescue Phronomy::GuardrailError
|
|
420
|
-
raise
|
|
421
|
-
rescue
|
|
422
|
-
if policy && attempt < policy[:times]
|
|
423
|
-
wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
|
|
424
|
-
self.class._sleep_proc.call(wait) if wait > 0
|
|
425
|
-
attempt += 1
|
|
426
|
-
retry
|
|
427
|
-
end
|
|
428
|
-
raise
|
|
429
|
-
end
|
|
415
|
+
thread_id = config[:thread_id]
|
|
416
|
+
_run_in_thread_actor(thread_id) { _invoke_impl(input, config: config) }
|
|
430
417
|
end
|
|
431
418
|
|
|
432
419
|
# Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
|
|
@@ -446,6 +433,76 @@ module Phronomy
|
|
|
446
433
|
def stream(input, config: {}, &block)
|
|
447
434
|
return invoke(input, config: config) unless block
|
|
448
435
|
|
|
436
|
+
thread_id = config[:thread_id]
|
|
437
|
+
_run_in_thread_actor(thread_id) { _stream_impl(input, config: config, &block) }
|
|
438
|
+
rescue => e
|
|
439
|
+
block&.call(StreamEvent.new(type: :error, payload: {error: e}))
|
|
440
|
+
raise
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Registers a callback that is invoked before executing any tool that has
|
|
444
|
+
# +requires_approval true+ set. The block receives the tool name (String)
|
|
445
|
+
# and the arguments Hash, and must return a truthy value to allow execution.
|
|
446
|
+
# Returning a falsy value causes the tool to return a denial message instead
|
|
447
|
+
# of executing.
|
|
448
|
+
#
|
|
449
|
+
# When no handler is registered, tools with +requires_approval+ execute
|
|
450
|
+
# without interruption (backward-compatible behaviour).
|
|
451
|
+
#
|
|
452
|
+
# @example
|
|
453
|
+
# agent = MyAgent.new
|
|
454
|
+
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
455
|
+
# @return [self]
|
|
456
|
+
def on_approval_required(&block)
|
|
457
|
+
@approval_handler = block
|
|
458
|
+
self
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Attach a guardrail that validates input before every #invoke call.
|
|
462
|
+
# @param guardrail [Phronomy::Guardrail::InputGuardrail]
|
|
463
|
+
def add_input_guardrail(guardrail)
|
|
464
|
+
@input_guardrails ||= []
|
|
465
|
+
@input_guardrails << guardrail
|
|
466
|
+
self
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Attach a guardrail that validates output before it is returned.
|
|
470
|
+
# @param guardrail [Phronomy::Guardrail::OutputGuardrail]
|
|
471
|
+
def add_output_guardrail(guardrail)
|
|
472
|
+
@output_guardrails ||= []
|
|
473
|
+
@output_guardrails << guardrail
|
|
474
|
+
self
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Returns the {Context::ContextVersionCache} for the current thread.
|
|
478
|
+
# @api private
|
|
479
|
+
def context_version_cache
|
|
480
|
+
(Thread.current[:phronomy_context_version_caches] ||= {})[object_id]
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
private
|
|
484
|
+
|
|
485
|
+
# Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
|
|
486
|
+
def _invoke_impl(input, config: {})
|
|
487
|
+
policy = self.class._retry_policy
|
|
488
|
+
attempt = 0
|
|
489
|
+
begin
|
|
490
|
+
invoke_once(input, config: config)
|
|
491
|
+
rescue Phronomy::GuardrailError
|
|
492
|
+
raise
|
|
493
|
+
rescue
|
|
494
|
+
if policy && attempt < policy[:times]
|
|
495
|
+
wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
|
|
496
|
+
self.class._sleep_proc.call(wait) if wait > 0
|
|
497
|
+
attempt += 1
|
|
498
|
+
retry
|
|
499
|
+
end
|
|
500
|
+
raise
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Streaming implementation for #stream.
|
|
505
|
+
def _stream_impl(input, config: {}, &block)
|
|
449
506
|
caller_meta = {}
|
|
450
507
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
451
508
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
@@ -528,47 +585,16 @@ module Phronomy
|
|
|
528
585
|
block.call(StreamEvent.new(type: :done, payload: result))
|
|
529
586
|
[result, usage]
|
|
530
587
|
end
|
|
531
|
-
rescue => e
|
|
532
|
-
block&.call(StreamEvent.new(type: :error, payload: {error: e}))
|
|
533
|
-
raise
|
|
534
588
|
end
|
|
535
589
|
|
|
536
|
-
#
|
|
537
|
-
# +
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
# of executing.
|
|
541
|
-
#
|
|
542
|
-
# When no handler is registered, tools with +requires_approval+ execute
|
|
543
|
-
# without interruption (backward-compatible behaviour).
|
|
544
|
-
#
|
|
545
|
-
# @example
|
|
546
|
-
# agent = MyAgent.new
|
|
547
|
-
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
548
|
-
# @return [self]
|
|
549
|
-
def on_approval_required(&block)
|
|
550
|
-
@approval_handler = block
|
|
551
|
-
self
|
|
552
|
-
end
|
|
590
|
+
# Runs +block+ inside the {Phronomy::ThreadActorRegistry} Actor for
|
|
591
|
+
# +thread_id+. When +thread_id+ is nil the block executes on the calling thread.
|
|
592
|
+
def _run_in_thread_actor(thread_id, &block)
|
|
593
|
+
return block.call unless thread_id
|
|
553
594
|
|
|
554
|
-
|
|
555
|
-
# @param guardrail [Phronomy::Guardrail::InputGuardrail]
|
|
556
|
-
def add_input_guardrail(guardrail)
|
|
557
|
-
@input_guardrails ||= []
|
|
558
|
-
@input_guardrails << guardrail
|
|
559
|
-
self
|
|
595
|
+
Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
|
|
560
596
|
end
|
|
561
597
|
|
|
562
|
-
# Attach a guardrail that validates output before it is returned.
|
|
563
|
-
# @param guardrail [Phronomy::Guardrail::OutputGuardrail]
|
|
564
|
-
def add_output_guardrail(guardrail)
|
|
565
|
-
@output_guardrails ||= []
|
|
566
|
-
@output_guardrails << guardrail
|
|
567
|
-
self
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
private
|
|
571
|
-
|
|
572
598
|
# Performs a single (non-retried) invocation. Extracted so that #invoke can
|
|
573
599
|
# wrap it in a retry loop without duplicating the LLM interaction logic.
|
|
574
600
|
def invoke_once(input, config: {})
|
|
@@ -790,7 +816,9 @@ module Phronomy
|
|
|
790
816
|
[instruction.to_s, *static_chunks.map { |c| c[:content] }].join("\0")
|
|
791
817
|
)
|
|
792
818
|
|
|
793
|
-
|
|
819
|
+
agent_id = object_id
|
|
820
|
+
cache = (Thread.current[:phronomy_context_version_caches] ||= {})[agent_id] ||=
|
|
821
|
+
Context::ContextVersionCache.new
|
|
794
822
|
unless cache.valid?(fingerprint)
|
|
795
823
|
parts = [instruction]
|
|
796
824
|
static_chunks.each do |chunk|
|
|
@@ -2,20 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Context
|
|
5
|
-
# Caches the assembled static system prompt text
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# instruction text and the content of all registered static knowledge
|
|
9
|
-
# sources. When the fingerprint matches the stored value the previously
|
|
10
|
-
# assembled system_text is reused without re-fetching any sources.
|
|
11
|
-
#
|
|
12
|
-
# A cache miss (fingerprint changed or first call) triggers a full
|
|
13
|
-
# rebuild: instruction + static-knowledge XML tags are concatenated and
|
|
14
|
-
# the result is stored alongside the new fingerprint.
|
|
15
|
-
#
|
|
16
|
-
# Each agent *instance* holds one cache object. The cache persists across
|
|
17
|
-
# #invoke calls on the same instance, which is the typical usage pattern
|
|
18
|
-
# for long-running agents.
|
|
5
|
+
# Caches the assembled static system prompt text keyed by a SHA-256
|
|
6
|
+
# fingerprint of the agent's instructions + static knowledge content.
|
|
7
|
+
# Each instance is owned by one thread (stored in +Thread.current+).
|
|
19
8
|
class ContextVersionCache
|
|
20
9
|
# @return [String, nil] last stored fingerprint
|
|
21
10
|
attr_reader :fingerprint
|
|
@@ -27,46 +16,34 @@ module Phronomy
|
|
|
27
16
|
attr_reader :system_tokens
|
|
28
17
|
|
|
29
18
|
def initialize
|
|
30
|
-
@mutex = Mutex.new
|
|
31
19
|
@fingerprint = nil
|
|
32
20
|
@system_text = nil
|
|
33
21
|
@system_tokens = 0
|
|
34
22
|
end
|
|
35
23
|
|
|
36
24
|
# Returns true when the given fingerprint matches the stored one.
|
|
37
|
-
# The check is performed under a mutex so that a concurrent #update cannot
|
|
38
|
-
# expose a partially-written state where fingerprint is new but system_text
|
|
39
|
-
# is still nil (Issue #55).
|
|
40
25
|
#
|
|
41
26
|
# @param fingerprint [String] SHA-256 hex digest to compare
|
|
42
27
|
# @return [Boolean]
|
|
43
28
|
def valid?(fingerprint)
|
|
44
|
-
|
|
45
|
-
!@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
|
|
46
|
-
end
|
|
29
|
+
!@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
|
|
47
30
|
end
|
|
48
31
|
|
|
49
32
|
# Update the cache with a new fingerprint and system text.
|
|
50
|
-
# All three assignments are performed atomically under a mutex so that
|
|
51
|
-
# concurrent readers never observe a partial state (Issue #55).
|
|
52
33
|
#
|
|
53
34
|
# @param fingerprint [String] new SHA-256 hex digest
|
|
54
35
|
# @param system_text [String] fully assembled system prompt text
|
|
55
36
|
def update(fingerprint:, system_text:)
|
|
56
|
-
@
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@system_tokens = TokenEstimator.estimate(@system_text)
|
|
60
|
-
end
|
|
37
|
+
@fingerprint = fingerprint
|
|
38
|
+
@system_text = system_text.to_s
|
|
39
|
+
@system_tokens = TokenEstimator.estimate(@system_text)
|
|
61
40
|
end
|
|
62
41
|
|
|
63
42
|
# Clear all cached values (used for testing and forced invalidation).
|
|
64
43
|
def reset
|
|
65
|
-
@
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@system_tokens = 0
|
|
69
|
-
end
|
|
44
|
+
@fingerprint = nil
|
|
45
|
+
@system_text = nil
|
|
46
|
+
@system_tokens = 0
|
|
70
47
|
end
|
|
71
48
|
end
|
|
72
49
|
end
|
|
@@ -48,16 +48,6 @@ module Phronomy
|
|
|
48
48
|
@retrieval = retrieval
|
|
49
49
|
@compression = compression
|
|
50
50
|
@ttl = ttl
|
|
51
|
-
# Per-thread mutexes allow concurrent saves for different thread_ids while
|
|
52
|
-
# preventing races (duplicate compaction records) within the same thread_id.
|
|
53
|
-
@thread_mutexes = {}
|
|
54
|
-
@thread_mutexes_mutex = Mutex.new
|
|
55
|
-
# Tracks the monotonically increasing next-seq per thread so that TTL
|
|
56
|
-
# purges (which reduce raw.length) do not reset the sequence counter.
|
|
57
|
-
# Protected by a dedicated mutex so concurrent saves for distinct
|
|
58
|
-
# thread_ids do not race on the shared Hash (Issue #60).
|
|
59
|
-
@raw_seq_hwm = {}
|
|
60
|
-
@raw_seq_hwm_mutex = Mutex.new
|
|
61
51
|
end
|
|
62
52
|
|
|
63
53
|
# Load conversation messages for a thread, applying retrieval selection.
|
|
@@ -92,8 +82,8 @@ module Phronomy
|
|
|
92
82
|
# @param thread_id [String]
|
|
93
83
|
# @param messages [Array] full conversation history up to this point
|
|
94
84
|
def save(thread_id:, messages:)
|
|
95
|
-
|
|
96
|
-
|
|
85
|
+
@storage.with_thread_lock(thread_id: thread_id) do
|
|
86
|
+
append_new_messages(thread_id: thread_id, messages: messages)
|
|
97
87
|
compress_and_save(thread_id: thread_id, messages: messages)
|
|
98
88
|
end
|
|
99
89
|
@retrieval.index(thread_id: thread_id, messages: messages) if @retrieval.respond_to?(:index)
|
|
@@ -135,36 +125,17 @@ module Phronomy
|
|
|
135
125
|
|
|
136
126
|
private
|
|
137
127
|
|
|
138
|
-
# Returns (or lazily creates) the per-thread mutex for +thread_id+.
|
|
139
|
-
# The outer @thread_mutexes_mutex protects the hash from concurrent creation.
|
|
140
|
-
def thread_mutex(thread_id)
|
|
141
|
-
@thread_mutexes_mutex.synchronize do
|
|
142
|
-
@thread_mutexes[thread_id] ||= Mutex.new
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
|
|
146
128
|
# Append messages that are new since the last save to the raw history.
|
|
147
|
-
# Must be called while holding the per-thread
|
|
129
|
+
# Must be called while holding the per-thread lock (via Storage#with_thread_lock).
|
|
148
130
|
# Messages are append-only; existing raw entries are never modified.
|
|
149
131
|
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def append_new_messages_unlocked(thread_id:, messages:)
|
|
156
|
-
raw = @storage.load_raw(thread_id: thread_id)
|
|
157
|
-
# Derive the next seq from the raw store's high-water-mark seq when
|
|
158
|
-
# entries are present. Fall back to the in-memory HWM when the raw
|
|
159
|
-
# store has been partially or fully purged by TTL expiry.
|
|
160
|
-
stored_next_seq = raw.any? ? raw.map { |e| e[:seq] }.max + 1 : nil
|
|
161
|
-
hwm = @raw_seq_hwm_mutex.synchronize { @raw_seq_hwm[thread_id] }
|
|
162
|
-
next_seq = [stored_next_seq, hwm].compact.max || 0
|
|
132
|
+
# The next seq number is derived from Storage#next_seq, which owns the
|
|
133
|
+
# high-water-mark counter. This survives TTL purges because Storage tracks
|
|
134
|
+
# the HWM independently of the stored raw entries.
|
|
135
|
+
def append_new_messages(thread_id:, messages:)
|
|
136
|
+
next_seq = @storage.next_seq(thread_id: thread_id)
|
|
163
137
|
new_messages = messages[next_seq..]
|
|
164
|
-
if new_messages&.any?
|
|
165
|
-
@storage.append_raw(thread_id: thread_id, messages: new_messages, starting_seq: next_seq)
|
|
166
|
-
@raw_seq_hwm_mutex.synchronize { @raw_seq_hwm[thread_id] = next_seq + new_messages.length }
|
|
167
|
-
end
|
|
138
|
+
@storage.append_raw(thread_id: thread_id, messages: new_messages, starting_seq: next_seq) if new_messages&.any?
|
|
168
139
|
end
|
|
169
140
|
|
|
170
141
|
# Apply the configured compression strategy and persist the result.
|
|
@@ -28,7 +28,7 @@ module Phronomy
|
|
|
28
28
|
@index = {} # id => message (insertion-ordered via Ruby Hash)
|
|
29
29
|
@counter = 0
|
|
30
30
|
@max_index_size = max_index_size
|
|
31
|
-
@
|
|
31
|
+
@actor = Phronomy::Actor.new
|
|
32
32
|
@indexed_object_ids = {} # thread_id => { object_id => true }
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -43,14 +43,14 @@ module Phronomy
|
|
|
43
43
|
def index(thread_id:, messages:)
|
|
44
44
|
messages.each do |msg|
|
|
45
45
|
# Fast path: skip already-indexed messages without calling embed.
|
|
46
|
-
already_indexed = @
|
|
46
|
+
already_indexed = @actor.call do
|
|
47
47
|
(@indexed_object_ids[thread_id] ||= {})[msg.object_id]
|
|
48
48
|
end
|
|
49
49
|
next if already_indexed
|
|
50
50
|
|
|
51
51
|
embedding = @embeddings.embed(msg.content.to_s)
|
|
52
|
-
@
|
|
53
|
-
# Re-check inside
|
|
52
|
+
@actor.call do
|
|
53
|
+
# Re-check inside Actor to handle concurrent callers for the same thread.
|
|
54
54
|
indexed = (@indexed_object_ids[thread_id] ||= {})
|
|
55
55
|
next if indexed[msg.object_id]
|
|
56
56
|
|
|
@@ -68,7 +68,7 @@ module Phronomy
|
|
|
68
68
|
#
|
|
69
69
|
# @param thread_id [String]
|
|
70
70
|
def clear_index(thread_id:)
|
|
71
|
-
@
|
|
71
|
+
@actor.call do
|
|
72
72
|
ids = @index.keys.select { |id| id.start_with?("#{thread_id}:") }
|
|
73
73
|
ids.each do |id|
|
|
74
74
|
@index.delete(id)
|
|
@@ -87,7 +87,7 @@ module Phronomy
|
|
|
87
87
|
def select(messages, query: nil, thread_id: nil)
|
|
88
88
|
if query && !query.strip.empty?
|
|
89
89
|
query_embedding = @embeddings.embed(query)
|
|
90
|
-
results = @
|
|
90
|
+
results = @actor.call { @store.search(query_embedding: query_embedding, k: @k * 3) }
|
|
91
91
|
results
|
|
92
92
|
.select { |r| thread_id.nil? || r[:metadata][:thread_id] == thread_id }
|
|
93
93
|
.first(@k)
|
|
@@ -100,7 +100,7 @@ module Phronomy
|
|
|
100
100
|
private
|
|
101
101
|
|
|
102
102
|
# Evicts the oldest index entry to enforce max_index_size.
|
|
103
|
-
# Must be called inside
|
|
103
|
+
# Must be called inside the Actor.
|
|
104
104
|
def evict_oldest!
|
|
105
105
|
oldest_id = @index.keys.first
|
|
106
106
|
return unless oldest_id
|