phronomy 0.1.3 → 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/generators/phronomy/install/templates/create_phronomy_messages.rb.tt +1 -1
- data/lib/phronomy/actor.rb +68 -0
- data/lib/phronomy/agent/base.rb +125 -91
- data/lib/phronomy/agent/handoff.rb +2 -2
- data/lib/phronomy/agent/react_agent.rb +51 -33
- data/lib/phronomy/context/assembler.rb +11 -3
- data/lib/phronomy/context/compaction_context.rb +1 -3
- data/lib/phronomy/context/context_version_cache.rb +7 -16
- data/lib/phronomy/eval/runner.rb +39 -11
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +47 -3
- data/lib/phronomy/memory/compression/summary.rb +4 -3
- data/lib/phronomy/memory/compression/tool_output_pruner.rb +11 -6
- data/lib/phronomy/memory/conversation_manager.rb +25 -16
- data/lib/phronomy/memory/retrieval/semantic.rb +21 -5
- data/lib/phronomy/memory/storage/active_record.rb +32 -10
- 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 -9
- data/lib/phronomy/state_store/redis.rb +1 -1
- data/lib/phronomy/thread_actor_registry.rb +52 -0
- data/lib/phronomy/tool/base.rb +9 -2
- data/lib/phronomy/tool/mcp_tool.rb +28 -4
- data/lib/phronomy/tracing/base.rb +0 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +24 -6
- data/lib/phronomy/tracing/null_tracer.rb +6 -3
- data/lib/phronomy/trust_pipeline.rb +60 -52
- data/lib/phronomy/vector_store/redis_search.rb +28 -23
- 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 -183
- 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 -148
- 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
|
|
@@ -3,7 +3,7 @@ class CreatePhronomyMessages < ActiveRecord::Migration[<%= ActiveRecord::Migrati
|
|
|
3
3
|
create_table :phronomy_messages do |t|
|
|
4
4
|
t.string :thread_id, null: false
|
|
5
5
|
t.string :role, null: false
|
|
6
|
-
t.text :content
|
|
6
|
+
t.text :content
|
|
7
7
|
t.text :tool_calls_json
|
|
8
8
|
t.string :model_id
|
|
9
9
|
t.timestamps
|
|
@@ -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,82 +433,8 @@ module Phronomy
|
|
|
446
433
|
def stream(input, config: {}, &block)
|
|
447
434
|
return invoke(input, config: config) unless block
|
|
448
435
|
|
|
449
|
-
run_input_guardrails!(input)
|
|
450
|
-
|
|
451
|
-
memory = config[:memory]
|
|
452
436
|
thread_id = config[:thread_id]
|
|
453
|
-
|
|
454
|
-
chat = build_chat
|
|
455
|
-
user_message = extract_message(input)
|
|
456
|
-
budget = build_token_budget
|
|
457
|
-
|
|
458
|
-
# Assemble context via Assembler (same as invoke_once).
|
|
459
|
-
assembler = Context::Assembler.new(budget: budget)
|
|
460
|
-
system_msg = build_instructions(input)
|
|
461
|
-
assembler.add_instruction(system_msg) if system_msg
|
|
462
|
-
|
|
463
|
-
Array(config[:knowledge_sources]).each do |ks|
|
|
464
|
-
ks.fetch(query: user_message).each do |chunk|
|
|
465
|
-
assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
|
|
466
|
-
end
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
if memory && thread_id
|
|
470
|
-
msgs = load_from_memory(memory, thread_id: thread_id, query: user_message)
|
|
471
|
-
message_elements = build_message_elements(msgs)
|
|
472
|
-
|
|
473
|
-
# Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
|
|
474
|
-
if (trim_cb = self.class._on_trim_callback)
|
|
475
|
-
trim_ctx = Context::TrimContext.new(message_elements: message_elements, budget: budget)
|
|
476
|
-
trim_cb.call(trim_ctx)
|
|
477
|
-
message_elements = trim_ctx.message_elements
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
# Run on_compaction_trigger → on_compact pipeline before calling the LLM.
|
|
481
|
-
if (trigger_cb = self.class._on_compaction_trigger_callback)
|
|
482
|
-
trigger_ctx = Context::TriggerContext.new(message_elements: message_elements, budget: budget)
|
|
483
|
-
if trigger_cb.call(trigger_ctx)
|
|
484
|
-
if (compact_cb = self.class._on_compact_callback)
|
|
485
|
-
compact_ctx = Context::CompactionContext.new(
|
|
486
|
-
message_elements: message_elements,
|
|
487
|
-
budget: budget,
|
|
488
|
-
thread_id: thread_id,
|
|
489
|
-
memory: memory
|
|
490
|
-
)
|
|
491
|
-
compact_cb.call(compact_ctx)
|
|
492
|
-
message_elements = build_message_elements(compact_ctx.result_messages)
|
|
493
|
-
end
|
|
494
|
-
end
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
assembler.add_messages(message_elements.map { |e| e[:message] })
|
|
498
|
-
end
|
|
499
|
-
|
|
500
|
-
context = assembler.build
|
|
501
|
-
apply_instructions(chat, context[:system]) if context[:system]
|
|
502
|
-
context[:messages].each { |msg| chat.messages << msg }
|
|
503
|
-
|
|
504
|
-
# Wire per-event callbacks to yield StreamEvents.
|
|
505
|
-
chat.on_tool_call { |tool_call| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call})) }
|
|
506
|
-
chat.on_tool_result { |tool_result| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tool_result})) }
|
|
507
|
-
|
|
508
|
-
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
509
|
-
run_before_completion_hooks!(chat, config)
|
|
510
|
-
|
|
511
|
-
response = chat.ask(user_message) do |chunk|
|
|
512
|
-
block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
save_to_memory(memory, thread_id: thread_id, messages: chat.messages) if memory && thread_id
|
|
516
|
-
|
|
517
|
-
output = response.content
|
|
518
|
-
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
519
|
-
|
|
520
|
-
run_output_guardrails!(output)
|
|
521
|
-
|
|
522
|
-
result = {output: output, messages: chat.messages, usage: usage}
|
|
523
|
-
block.call(StreamEvent.new(type: :done, payload: result))
|
|
524
|
-
result
|
|
437
|
+
_run_in_thread_actor(thread_id) { _stream_impl(input, config: config, &block) }
|
|
525
438
|
rescue => e
|
|
526
439
|
block&.call(StreamEvent.new(type: :error, payload: {error: e}))
|
|
527
440
|
raise
|
|
@@ -561,8 +474,127 @@ module Phronomy
|
|
|
561
474
|
self
|
|
562
475
|
end
|
|
563
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
|
+
|
|
564
483
|
private
|
|
565
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)
|
|
506
|
+
caller_meta = {}
|
|
507
|
+
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
508
|
+
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
509
|
+
|
|
510
|
+
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
511
|
+
run_input_guardrails!(input)
|
|
512
|
+
|
|
513
|
+
memory = config[:memory]
|
|
514
|
+
thread_id = config[:thread_id]
|
|
515
|
+
|
|
516
|
+
chat = build_chat
|
|
517
|
+
user_message = extract_message(input)
|
|
518
|
+
budget = build_token_budget
|
|
519
|
+
|
|
520
|
+
# Assemble context via Assembler (same as invoke_once).
|
|
521
|
+
assembler = Context::Assembler.new(budget: budget)
|
|
522
|
+
system_msg = build_instructions(input)
|
|
523
|
+
assembler.add_instruction(system_msg) if system_msg
|
|
524
|
+
|
|
525
|
+
Array(config[:knowledge_sources]).each do |ks|
|
|
526
|
+
ks.fetch(query: user_message).each do |chunk|
|
|
527
|
+
assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
if memory && thread_id
|
|
532
|
+
msgs = load_from_memory(memory, thread_id: thread_id, query: user_message)
|
|
533
|
+
message_elements = build_message_elements(msgs)
|
|
534
|
+
|
|
535
|
+
# Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
|
|
536
|
+
if (trim_cb = self.class._on_trim_callback)
|
|
537
|
+
trim_ctx = Context::TrimContext.new(message_elements: message_elements, budget: budget)
|
|
538
|
+
trim_cb.call(trim_ctx)
|
|
539
|
+
message_elements = trim_ctx.message_elements
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Run on_compaction_trigger → on_compact pipeline before calling the LLM.
|
|
543
|
+
if (trigger_cb = self.class._on_compaction_trigger_callback)
|
|
544
|
+
trigger_ctx = Context::TriggerContext.new(message_elements: message_elements, budget: budget)
|
|
545
|
+
if trigger_cb.call(trigger_ctx)
|
|
546
|
+
if (compact_cb = self.class._on_compact_callback)
|
|
547
|
+
compact_ctx = Context::CompactionContext.new(
|
|
548
|
+
message_elements: message_elements,
|
|
549
|
+
budget: budget,
|
|
550
|
+
thread_id: thread_id,
|
|
551
|
+
memory: memory
|
|
552
|
+
)
|
|
553
|
+
compact_cb.call(compact_ctx)
|
|
554
|
+
message_elements = build_message_elements(compact_ctx.result_messages)
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
assembler.add_messages(message_elements.map { |e| e[:message] })
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
context = assembler.build
|
|
563
|
+
apply_instructions(chat, context[:system]) if context[:system]
|
|
564
|
+
context[:messages].each { |msg| chat.messages << msg }
|
|
565
|
+
|
|
566
|
+
# Wire per-event callbacks to yield StreamEvents.
|
|
567
|
+
chat.before_tool_call { |tool_call| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call})) }
|
|
568
|
+
chat.after_tool_result { |tool_result| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tool_result})) }
|
|
569
|
+
|
|
570
|
+
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
571
|
+
run_before_completion_hooks!(chat, config)
|
|
572
|
+
|
|
573
|
+
response = chat.ask(user_message) do |chunk|
|
|
574
|
+
block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
save_to_memory(memory, thread_id: thread_id, messages: chat.messages) if memory && thread_id
|
|
578
|
+
|
|
579
|
+
output = response.content
|
|
580
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
581
|
+
|
|
582
|
+
run_output_guardrails!(output)
|
|
583
|
+
|
|
584
|
+
result = {output: output, messages: chat.messages, usage: usage}
|
|
585
|
+
block.call(StreamEvent.new(type: :done, payload: result))
|
|
586
|
+
[result, usage]
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
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
|
|
594
|
+
|
|
595
|
+
Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
|
|
596
|
+
end
|
|
597
|
+
|
|
566
598
|
# Performs a single (non-retried) invocation. Extracted so that #invoke can
|
|
567
599
|
# wrap it in a retry loop without duplicating the LLM interaction logic.
|
|
568
600
|
def invoke_once(input, config: {})
|
|
@@ -784,7 +816,9 @@ module Phronomy
|
|
|
784
816
|
[instruction.to_s, *static_chunks.map { |c| c[:content] }].join("\0")
|
|
785
817
|
)
|
|
786
818
|
|
|
787
|
-
|
|
819
|
+
agent_id = object_id
|
|
820
|
+
cache = (Thread.current[:phronomy_context_version_caches] ||= {})[agent_id] ||=
|
|
821
|
+
Context::ContextVersionCache.new
|
|
788
822
|
unless cache.valid?(fingerprint)
|
|
789
823
|
parts = [instruction]
|
|
790
824
|
static_chunks.each do |chunk|
|
|
@@ -25,10 +25,10 @@ module Phronomy
|
|
|
25
25
|
def initialize(target_agent:, description: nil)
|
|
26
26
|
@target_agent = target_agent
|
|
27
27
|
klass_name = target_agent.class.name&.split("::")&.last || "Agent"
|
|
28
|
-
@tool_name = "transfer_to_#{snake_case(klass_name)}"
|
|
29
|
-
@description = description || "Transfer the conversation to #{klass_name}."
|
|
30
28
|
# Use a UUID so that two handoffs targeting the same class remain distinct.
|
|
31
29
|
@uuid = SecureRandom.uuid
|
|
30
|
+
@tool_name = "transfer_to_#{snake_case(klass_name)}_#{@uuid.delete("-")[0, 8]}"
|
|
31
|
+
@description = description || "Transfer the conversation to #{klass_name}."
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
|