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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +49 -38
  4. data/docs/trustworthy_ai_enhancements.md +4 -4
  5. data/lib/generators/phronomy/install/templates/create_phronomy_messages.rb.tt +1 -1
  6. data/lib/phronomy/actor.rb +68 -0
  7. data/lib/phronomy/agent/base.rb +125 -91
  8. data/lib/phronomy/agent/handoff.rb +2 -2
  9. data/lib/phronomy/agent/react_agent.rb +51 -33
  10. data/lib/phronomy/context/assembler.rb +11 -3
  11. data/lib/phronomy/context/compaction_context.rb +1 -3
  12. data/lib/phronomy/context/context_version_cache.rb +7 -16
  13. data/lib/phronomy/eval/runner.rb +39 -11
  14. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +47 -3
  15. data/lib/phronomy/memory/compression/summary.rb +4 -3
  16. data/lib/phronomy/memory/compression/tool_output_pruner.rb +11 -6
  17. data/lib/phronomy/memory/conversation_manager.rb +25 -16
  18. data/lib/phronomy/memory/retrieval/semantic.rb +21 -5
  19. data/lib/phronomy/memory/storage/active_record.rb +32 -10
  20. data/lib/phronomy/memory/storage/base.rb +22 -0
  21. data/lib/phronomy/memory/storage/in_memory.rb +65 -26
  22. data/lib/phronomy/state_store/active_record.rb +1 -1
  23. data/lib/phronomy/state_store/base.rb +14 -16
  24. data/lib/phronomy/state_store/in_memory.rb +23 -9
  25. data/lib/phronomy/state_store/redis.rb +1 -1
  26. data/lib/phronomy/thread_actor_registry.rb +52 -0
  27. data/lib/phronomy/tool/base.rb +9 -2
  28. data/lib/phronomy/tool/mcp_tool.rb +28 -4
  29. data/lib/phronomy/tracing/base.rb +0 -2
  30. data/lib/phronomy/tracing/langfuse_tracer.rb +24 -6
  31. data/lib/phronomy/tracing/null_tracer.rb +6 -3
  32. data/lib/phronomy/trust_pipeline.rb +60 -52
  33. data/lib/phronomy/vector_store/redis_search.rb +28 -23
  34. data/lib/phronomy/version.rb +1 -1
  35. data/lib/phronomy/workflow.rb +281 -0
  36. data/lib/phronomy/workflow_context.rb +119 -0
  37. data/lib/phronomy/workflow_runner.rb +262 -0
  38. data/lib/phronomy.rb +30 -34
  39. metadata +25 -10
  40. data/lib/phronomy/graph/compiled_graph.rb +0 -183
  41. data/lib/phronomy/graph/parallel_node.rb +0 -193
  42. data/lib/phronomy/graph/state.rb +0 -105
  43. data/lib/phronomy/graph/state_graph.rb +0 -148
  44. data/lib/phronomy/graph.rb +0 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04a7eceda662bfc638c3ec07ac161299b2bb08863e18e9ba03e7a3226165a921
4
- data.tar.gz: 9938ace6e4a7250c08f733339af4de14c7d9ff62ff7c52a27eb954c0700d18f4
3
+ metadata.gz: c0aaadfedad1ee8b4afa1efb2e205a626a20cd636f268e711dce29128c84b6fe
4
+ data.tar.gz: f781f66ae3d570caca771d2b5579874169acef5eeed7f6cf99474a4c82fd077a
5
5
  SHA512:
6
- metadata.gz: 1dc032c438a407a751b5c74fd76d172796e852a3add651c1ca6bb210991d20305a1cc609fe157e48026293084943714569219d359794c9bc7fde0dc396ed16d1
7
- data.tar.gz: c1b52dcfa5196b92b72641f8d980856e5e827d61ff0e8afc61f5664f0556e0dbb0b047a7755d2f5f148525774c885ead4319510c32d7f2699fb4601c38d12ecd
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 — Graphs, Agents, and Memory — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
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
- - **Graph** — Build stateful, branching agent workflows with interrupt/resume support
9
- - **Graph Parallel Node** — Execute independent graph branches concurrently with configurable merge and error policies
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
- ### Graph — Stateful workflow with interrupt/resume
73
+ ### Workflow — Stateful workflow with wait_state/send_event
74
74
 
75
75
  ```ruby
76
- class ReviewState
77
- include Phronomy::Graph::State
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
- graph = Phronomy::Graph::StateGraph.new(ReviewState)
84
- graph.add_node(:write) { |s| { draft: Writer.call(s) } }
85
- graph.add_node(:review) { |s| { feedback: Reviewer.call(s.draft) } }
86
- graph.add_node(:finalize) { |s| { approved: true } }
87
- graph.add_edge(:write, :review)
88
- graph.add_edge(:review, :finalize)
89
- graph.set_entry_point(:write)
90
-
91
- # Register an interrupt callback before the :finalize node
92
- graph.interrupt_before(:finalize) do |state|
93
- puts "Draft ready for human review: #{state.draft}"
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
- compiled = graph.compile
97
- compiled.invoke({ draft: "" }, config: { thread_id: "doc-1" })
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
- ### Graph Parallel Node — Concurrent branches
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 MyState
245
- include Phronomy::Graph::State
254
+ class EnrichContext
255
+ include Phronomy::WorkflowContext
246
256
  field :summary, type: :replace
247
- field :tags, type: :append, default: -> { [] }
257
+ field :tags, type: :append, default: -> { [] }
248
258
  end
249
259
 
250
- graph = Phronomy::Graph::StateGraph.new(MyState)
251
-
252
- graph.add_parallel_node(
253
- :enrich,
254
- ->(s) { { summary: Summarizer.call(s) } },
255
- ->(s) { { tags: Tagger.call(s) } },
256
- timeout: 10,
257
- on_error: :best_effort
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
- graph.set_entry_point(:enrich)
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 graph with interrupt/resume |
487
- | 04 | `04_interrupt_resume/` | Human-in-the-loop interrupt and resume |
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 | ✅ `interrupt_before/after`, `requires_approval`, `max_iterations` — see `lib/phronomy/graph/compiled_graph.rb`, `lib/phronomy/agent/base.rb` |
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 — `StateGraph`, `Tracing` |
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 `CompiledGraph#invoke` already accept `config: {}` — see
188
- `lib/phronomy/agent/base.rb:367` and `lib/phronomy/graph/compiled_graph.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, null: false
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
@@ -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
- policy = self.class._retry_policy
416
- attempt = 0
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
- cache = (@_context_version_cache ||= Context::ContextVersionCache.new)
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.