phronomy 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +35 -45
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_agent_invoke.rb +1 -1
- data/benchmark/bench_context_assembler.rb +11 -3
- data/benchmark/bench_regression.rb +11 -11
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +2 -2
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
- data/lib/phronomy/agent/base.rb +268 -403
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
- data/lib/phronomy/agent/context/capability/base.rb +689 -0
- data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/fsm.rb +1 -1
- data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +43 -37
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/shared_state.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/eval/runner.rb +4 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +7 -7
- data/lib/phronomy/invocation_context.rb +3 -3
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
- data/lib/phronomy/llm_context_window/assembler.rb +191 -0
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
- data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
- data/lib/phronomy/runtime.rb +20 -6
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool.rb +3 -4
- data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
- data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
- data/lib/phronomy/tools/vector_search.rb +70 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store/async_backend.rb +4 -4
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
- data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
- data/lib/phronomy/vector_store/in_memory.rb +12 -2
- data/lib/phronomy/vector_store/loader/base.rb +27 -0
- data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
- data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
- data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
- data/lib/phronomy/vector_store/pgvector.rb +2 -2
- data/lib/phronomy/vector_store/redis_search.rb +2 -2
- data/lib/phronomy/vector_store/splitter/base.rb +49 -0
- data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
- data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
- data/lib/phronomy/vector_store.rb +14 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_context.rb +8 -0
- data/lib/phronomy/workflow_runner.rb +11 -131
- data/lib/phronomy.rb +2 -0
- data/scripts/api_snapshot.rb +11 -9
- metadata +44 -46
- data/lib/phronomy/async_queue.rb +0 -155
- data/lib/phronomy/blocking_adapter_pool.rb +0 -435
- data/lib/phronomy/cancellation_scope.rb +0 -123
- data/lib/phronomy/cancellation_token.rb +0 -133
- data/lib/phronomy/concurrency_gate.rb +0 -155
- data/lib/phronomy/context/assembler.rb +0 -143
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/deadline.rb +0 -63
- data/lib/phronomy/embeddings/base.rb +0 -39
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/embeddings.rb +0 -11
- data/lib/phronomy/fsm_session.rb +0 -247
- data/lib/phronomy/knowledge_source/base.rb +0 -54
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/loader.rb +0 -13
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/splitter.rb +0 -12
- data/lib/phronomy/tool/base.rb +0 -644
- data/lib/phronomy/tool/scope_policy.rb +0 -50
- data/lib/phronomy/tool_executor.rb +0 -106
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Capability
|
|
7
|
+
# Evaluates whether a tool with a given scope may execute.
|
|
8
|
+
#
|
|
9
|
+
# A ScopePolicy is a callable that receives +(tool_class, scope, agent)+ and
|
|
10
|
+
# returns one of:
|
|
11
|
+
# +:allow+ — proceed immediately without an approval gate.
|
|
12
|
+
# +:reject+ — block execution; the tool returns a denial message.
|
|
13
|
+
# +:approve+ — delegate to the agent's approval handler (if registered);
|
|
14
|
+
# when no handler is registered the call is rejected.
|
|
15
|
+
#
|
|
16
|
+
# The {Default} instance is used automatically when no custom policy is
|
|
17
|
+
# configured on an agent.
|
|
18
|
+
#
|
|
19
|
+
# @example Custom policy that allows everything
|
|
20
|
+
# agent.scope_policy = ->(_tool_class, _scope, _agent) { :allow }
|
|
21
|
+
#
|
|
22
|
+
# @example Strict policy that rejects all write scopes
|
|
23
|
+
# agent.scope_policy = ->(_tc, scope, _agent) {
|
|
24
|
+
# scope == :write ? :reject : :allow
|
|
25
|
+
# }
|
|
26
|
+
class ScopePolicy
|
|
27
|
+
# Scopes that must go through an approval gate before execution.
|
|
28
|
+
APPROVAL_REQUIRED_SCOPES = %i[write admin external_network filesystem process external_process].freeze
|
|
29
|
+
|
|
30
|
+
# Scopes that are always permitted without approval.
|
|
31
|
+
ALWAYS_ALLOWED_SCOPES = %i[read_only].freeze
|
|
32
|
+
|
|
33
|
+
# Returns +:allow+ for always-allowed scopes, +:approve+ for high-risk
|
|
34
|
+
# scopes, and +:allow+ for anything else (including +nil+).
|
|
35
|
+
#
|
|
36
|
+
# @param _tool_class [Class]
|
|
37
|
+
# @param scope [Symbol, nil]
|
|
38
|
+
# @param _agent [Object]
|
|
39
|
+
# @return [:allow, :approve, :reject]
|
|
40
|
+
# @api private
|
|
41
|
+
def call(_tool_class, scope, _agent)
|
|
42
|
+
return :allow if scope.nil? || ALWAYS_ALLOWED_SCOPES.include?(scope)
|
|
43
|
+
return :approve if APPROVAL_REQUIRED_SCOPES.include?(scope)
|
|
44
|
+
|
|
45
|
+
:allow
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Shared singleton used when no custom policy is configured.
|
|
49
|
+
DEFAULT = new.freeze
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Instruction
|
|
7
|
+
# A prompt template that substitutes {{variable}} placeholders in a string.
|
|
8
|
+
#
|
|
9
|
+
# @example Simple human template
|
|
10
|
+
# t = Phronomy::Agent::Context::Instruction::PromptTemplate.new(template: "Translate to {{lang}}: {{text}}")
|
|
11
|
+
# t.format(lang: "French", text: "Hello")
|
|
12
|
+
# # => "Translate to French: Hello"
|
|
13
|
+
#
|
|
14
|
+
# @example With a system template
|
|
15
|
+
# t = Phronomy::Agent::Context::Instruction::PromptTemplate.new(
|
|
16
|
+
# template: "{{question}}",
|
|
17
|
+
# system_template: "You are a {{role}} assistant."
|
|
18
|
+
# )
|
|
19
|
+
# t.format_system(role: "helpful")
|
|
20
|
+
# # => "You are a helpful assistant."
|
|
21
|
+
#
|
|
22
|
+
# As a Runnable, #invoke accepts a Hash of variables and returns a Hash
|
|
23
|
+
# with :prompt (and optionally :system) keys.
|
|
24
|
+
class PromptTemplate
|
|
25
|
+
include Phronomy::Runnable
|
|
26
|
+
|
|
27
|
+
PLACEHOLDER = /\{\{(\w+)\}\}/
|
|
28
|
+
|
|
29
|
+
attr_reader :template, :system_template
|
|
30
|
+
|
|
31
|
+
# @param template [String] human message template with {{var}} placeholders
|
|
32
|
+
# @param system_template [String, nil] optional system message template
|
|
33
|
+
# @api public
|
|
34
|
+
def initialize(template:, system_template: nil)
|
|
35
|
+
@template = template
|
|
36
|
+
@system_template = system_template
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Substitute all {{var}} placeholders in the human template.
|
|
40
|
+
#
|
|
41
|
+
# @param variables [Hash{Symbol => String}]
|
|
42
|
+
# @return [String]
|
|
43
|
+
# @api public
|
|
44
|
+
def format(**variables)
|
|
45
|
+
substitute(@template, variables)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Substitute all {{var}} placeholders in the system template.
|
|
49
|
+
# Returns nil when no system template was set.
|
|
50
|
+
#
|
|
51
|
+
# @param variables [Hash{Symbol => String}]
|
|
52
|
+
# @return [String, nil]
|
|
53
|
+
# @api public
|
|
54
|
+
def format_system(**variables)
|
|
55
|
+
@system_template && substitute(@system_template, variables)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Runnable interface: accepts a Hash of variable values.
|
|
59
|
+
# Returns { prompt: String, system: String|nil }.
|
|
60
|
+
#
|
|
61
|
+
# @param input [Hash{Symbol => String}]
|
|
62
|
+
# @return [Hash]
|
|
63
|
+
# @api public
|
|
64
|
+
def invoke(input, config: {})
|
|
65
|
+
vars = normalize_input(input)
|
|
66
|
+
result = {prompt: format(**vars)}
|
|
67
|
+
sys = format_system(**vars)
|
|
68
|
+
result[:system] = sys if sys
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the list of placeholder names found in both templates.
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<Symbol>]
|
|
75
|
+
# @api public
|
|
76
|
+
def variables
|
|
77
|
+
names = @template.scan(PLACEHOLDER).flatten
|
|
78
|
+
names += @system_template.scan(PLACEHOLDER).flatten if @system_template
|
|
79
|
+
names.map(&:to_sym).uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def substitute(text, variables)
|
|
85
|
+
text.gsub(PLACEHOLDER) do |match|
|
|
86
|
+
key = Regexp.last_match(1).to_sym
|
|
87
|
+
variables.fetch(key) { raise KeyError, "Missing variable: {{#{key}}}" }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def normalize_input(input)
|
|
92
|
+
case input
|
|
93
|
+
when Hash then input
|
|
94
|
+
when String then {input: input}
|
|
95
|
+
else raise ArgumentError, "PromptTemplate#invoke expects a Hash of variables, got #{input.class}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
# Abstract base class for all KnowledgeSource implementations.
|
|
8
|
+
#
|
|
9
|
+
# Subclasses must implement #fetch(query:) and return an Array of chunk Hashes.
|
|
10
|
+
# Each chunk Hash must contain:
|
|
11
|
+
# :content [String] the text to inject into the context
|
|
12
|
+
# :type [Symbol] semantic tag (e.g. :static, :rag, :entity)
|
|
13
|
+
class Base
|
|
14
|
+
# Retrieve knowledge chunks relevant to the given query.
|
|
15
|
+
#
|
|
16
|
+
# @param query [String, nil] the current user input used to select relevant chunks
|
|
17
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional token; raises CancellationError when cancelled
|
|
18
|
+
# @return [Array<Hash>] array of { content: String, type: Symbol }
|
|
19
|
+
# @api public
|
|
20
|
+
def fetch(query: nil, cancellation_token: nil)
|
|
21
|
+
cancellation_token&.raise_if_cancelled!
|
|
22
|
+
raise NotImplementedError, "#{self.class}#fetch is not implemented"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Submits a {#fetch} call to {BlockingAdapterPool} and returns a
|
|
26
|
+
# {BlockingAdapterPool::PendingOperation}.
|
|
27
|
+
# Callers can fan out multiple fetches in parallel and await them all.
|
|
28
|
+
#
|
|
29
|
+
# @param query [String, nil]
|
|
30
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
31
|
+
# @param timeout [Numeric, nil] seconds before the operation is abandoned
|
|
32
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
33
|
+
# @api public
|
|
34
|
+
def fetch_async(query: nil, cancellation_token: nil, timeout: nil)
|
|
35
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
36
|
+
timeout: timeout,
|
|
37
|
+
cancellation_token: cancellation_token
|
|
38
|
+
) do
|
|
39
|
+
fetch(query: query, cancellation_token: cancellation_token)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns true when this source's content is considered static (i.e. does
|
|
44
|
+
# not change between agent invocations). Static sources are eligible for
|
|
45
|
+
# fingerprint-based caching in ContextVersionCache.
|
|
46
|
+
#
|
|
47
|
+
# Override in subclasses that return fixed content.
|
|
48
|
+
#
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
# @api public
|
|
51
|
+
def static?
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
# A KnowledgeSource that extracts named-entity facts from conversation history.
|
|
8
|
+
#
|
|
9
|
+
# This is the knowledge-injection counterpart of the old EntityMemory.
|
|
10
|
+
# It scans saved user messages with a regex heuristic (no LLM call) and
|
|
11
|
+
# returns the discovered facts as a single knowledge chunk tagged :entity.
|
|
12
|
+
#
|
|
13
|
+
# EntityKnowledge is stateful: it accumulates extracted facts via #update(messages:)
|
|
14
|
+
# which should be called each time new messages are saved.
|
|
15
|
+
#
|
|
16
|
+
# Supported extraction patterns (case-insensitive):
|
|
17
|
+
# "my name is Alice" → { name: "Alice" }
|
|
18
|
+
# "I am Alice" → { identity: "Alice" }
|
|
19
|
+
# "I'm a software engineer" → { occupation: "software engineer" }
|
|
20
|
+
# "I work at / for Acme" → { workplace: "Acme" }
|
|
21
|
+
# "I live in Tokyo" → { location: "Tokyo" }
|
|
22
|
+
# "I'm from Tokyo" → { location: "Tokyo" }
|
|
23
|
+
# "I like / love Ruby" → { preference: "Ruby" }
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# ks = Phronomy::Agent::Context::Knowledge::EntityKnowledge.new
|
|
27
|
+
# ks.update(messages: chat_messages)
|
|
28
|
+
# agent = MyAgent.new
|
|
29
|
+
# agent.add_knowledge_source(ks)
|
|
30
|
+
# agent.invoke("What is my name?")
|
|
31
|
+
class EntityKnowledge < Base
|
|
32
|
+
PATTERNS = [
|
|
33
|
+
[:name, /\bmy name is\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
|
|
34
|
+
[:identity, /\bI\s+am\s+([A-Z][A-Za-z0-9 \-']+)/],
|
|
35
|
+
[:occupation, /\bI(?:'m| am) a(?:n)?\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
|
|
36
|
+
[:workplace, /\bI (?:work|worked) (?:at|for|in)\s+([A-Za-z0-9][A-Za-z0-9 \-'.&,]*)/i],
|
|
37
|
+
[:location, /\bI live in\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
|
|
38
|
+
[:location, /\bI(?:'m| am) from\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
|
|
39
|
+
[:preference, /\bI (?:like|love|enjoy)\s+([A-Za-z][A-Za-z0-9 \-']*)/i]
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@entities = {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Scan messages and accumulate entity facts.
|
|
47
|
+
# Call this after saving a new set of messages (e.g. from a ConversationManager save hook).
|
|
48
|
+
#
|
|
49
|
+
# @param messages [Array] message objects responding to #role and #content
|
|
50
|
+
# @api public
|
|
51
|
+
def update(messages:)
|
|
52
|
+
messages.each do |msg|
|
|
53
|
+
next unless msg.role.to_sym == :user
|
|
54
|
+
|
|
55
|
+
extract(msg.content.to_s).each { |key, value| @entities[key] = value }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns a single chunk containing all known entity facts in XML context format.
|
|
60
|
+
# Returns an empty array when no entities have been discovered.
|
|
61
|
+
#
|
|
62
|
+
# @param query [String, nil] unused — entity knowledge is always fully injected
|
|
63
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
64
|
+
# @return [Array<Hash>]
|
|
65
|
+
# @api public
|
|
66
|
+
def fetch(query: nil, cancellation_token: nil)
|
|
67
|
+
cancellation_token&.raise_if_cancelled!
|
|
68
|
+
return [] if @entities.empty?
|
|
69
|
+
|
|
70
|
+
lines = @entities.map { |key, value| "- #{key}: #{value}" }.join("\n")
|
|
71
|
+
content = <<~CONTENT.chomp
|
|
72
|
+
Known facts about the user:
|
|
73
|
+
#{lines}
|
|
74
|
+
CONTENT
|
|
75
|
+
[{content: content, type: :entity}]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the current entity store (primarily for testing).
|
|
79
|
+
#
|
|
80
|
+
# @return [Hash]
|
|
81
|
+
# @api public
|
|
82
|
+
def entities
|
|
83
|
+
@entities.dup
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def extract(text)
|
|
89
|
+
found = {}
|
|
90
|
+
PATTERNS.each do |key, pattern|
|
|
91
|
+
if (match = text.match(pattern))
|
|
92
|
+
value = match[1].strip.sub(/[.!?]\s+.*$/, "").gsub(/[.,;!?]+$/, "")
|
|
93
|
+
found[key] = value unless value.empty?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
found
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
# A KnowledgeSource backed by fixed text provided at construction time.
|
|
8
|
+
#
|
|
9
|
+
# Useful for injecting static documents, policy files, or configuration
|
|
10
|
+
# knowledge that does not change per request.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# ks = Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(
|
|
14
|
+
# "Our refund policy: ...",
|
|
15
|
+
# type: :policy
|
|
16
|
+
# )
|
|
17
|
+
# agent = MyAgent.new
|
|
18
|
+
# agent.add_knowledge_source(ks)
|
|
19
|
+
# agent.invoke("What is the refund policy?")
|
|
20
|
+
class StaticKnowledge < Base
|
|
21
|
+
# @param text [String] the static knowledge text to inject
|
|
22
|
+
# @param type [Symbol] semantic tag for the chunk (default :static)
|
|
23
|
+
# @param source [String, nil] label identifying where this knowledge came from
|
|
24
|
+
# (e.g. a filename). Included in the context XML tag and exposed to the LLM
|
|
25
|
+
# so that agents can produce grounded citations.
|
|
26
|
+
# @api public
|
|
27
|
+
def initialize(text, type: :static, source: nil)
|
|
28
|
+
@text = text.to_s
|
|
29
|
+
@type = type
|
|
30
|
+
@source = source
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the fixed text as a single chunk, regardless of query.
|
|
34
|
+
#
|
|
35
|
+
# @param query [String, nil] ignored for static knowledge
|
|
36
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
37
|
+
# @return [Array<Hash>]
|
|
38
|
+
# @api public
|
|
39
|
+
def fetch(query: nil, cancellation_token: nil)
|
|
40
|
+
cancellation_token&.raise_if_cancelled!
|
|
41
|
+
return [] if @text.empty?
|
|
42
|
+
|
|
43
|
+
chunk = {content: @text, type: @type}
|
|
44
|
+
chunk[:source] = @source if @source
|
|
45
|
+
[chunk]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Static knowledge content never changes between invocations.
|
|
49
|
+
# @return [true]
|
|
50
|
+
# @api public
|
|
51
|
+
def static?
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/phronomy/agent/fsm.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Phronomy
|
|
|
8
8
|
#
|
|
9
9
|
# +AgentFSM+ implements the minimal interface expected by {Phronomy::EventLoop}
|
|
10
10
|
# (+#id+, +#start+, +#handle+) so it can be managed alongside
|
|
11
|
-
# {Phronomy::FSMSession} instances. It is *not* a traditional finite-state
|
|
11
|
+
# {Phronomy::Agent::Lifecycle::FSMSession} instances. It is *not* a traditional finite-state
|
|
12
12
|
# machine; the name reflects its role in the EventLoop rather than internal
|
|
13
13
|
# state transitions.
|
|
14
14
|
#
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Encapsulates the core per-invocation LLM round-trip for {Agent::Base}.
|
|
6
|
+
#
|
|
7
|
+
# {Agent::Base#invoke_once} delegates the body of each LLM turn to this
|
|
8
|
+
# class, keeping the caller to a thin setup + trace frame (span≈2).
|
|
9
|
+
# The pipeline executes inside the agent's binding via +instance_exec+
|
|
10
|
+
# so that private concern methods (guardrails, hooks, cancellation) remain
|
|
11
|
+
# encapsulated in their original modules while the orchestration logic lives
|
|
12
|
+
# here.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class InvocationPipeline
|
|
16
|
+
# @param agent [Agent::Base] the agent instance driving this invocation
|
|
17
|
+
# @api private
|
|
18
|
+
def initialize(agent)
|
|
19
|
+
@agent = agent
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Runs one LLM round-trip inside the agent's execution context.
|
|
23
|
+
#
|
|
24
|
+
# Calls private {Agent::Base} concern methods (guardrails, hooks,
|
|
25
|
+
# cancellation) via +instance_exec+ so that their encapsulation is
|
|
26
|
+
# preserved, then routes the LLM request through the configured adapter.
|
|
27
|
+
#
|
|
28
|
+
# @param input [String, Hash] the user input for this turn
|
|
29
|
+
# @param messages [Array] prior conversation messages
|
|
30
|
+
# @param thread_id [String, nil] persistence thread identifier
|
|
31
|
+
# @param config [Hash] per-invocation options
|
|
32
|
+
# @return [Array(Hash, Phronomy::TokenUsage, nil)]
|
|
33
|
+
# A two-element array: the result hash and the token usage (or nil on
|
|
34
|
+
# suspension).
|
|
35
|
+
# @api private
|
|
36
|
+
def run(input, messages:, thread_id:, config:)
|
|
37
|
+
@agent.instance_exec(input, messages, thread_id, config) do |inp, msgs, tid, cfg|
|
|
38
|
+
# Run input guardrails before touching the LLM.
|
|
39
|
+
run_input_guardrails!(inp)
|
|
40
|
+
|
|
41
|
+
user_message = extract_message(inp)
|
|
42
|
+
chat = build_chat
|
|
43
|
+
|
|
44
|
+
# Assemble context (system prompt + history). Override #build_context to
|
|
45
|
+
# inject custom context editing logic at the Agent subclass level.
|
|
46
|
+
context = build_context(
|
|
47
|
+
inp,
|
|
48
|
+
messages: msgs,
|
|
49
|
+
thread_id: tid,
|
|
50
|
+
config: cfg,
|
|
51
|
+
budget: build_token_budget,
|
|
52
|
+
instruction: build_instructions(inp),
|
|
53
|
+
tools: self.class.tools + _handoff_tools
|
|
54
|
+
)
|
|
55
|
+
apply_instructions(chat, context[:system]) if context[:system]
|
|
56
|
+
(context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
|
|
57
|
+
context[:messages].each { |msg| chat.messages << msg }
|
|
58
|
+
|
|
59
|
+
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
60
|
+
run_before_completion_hooks!(chat, cfg)
|
|
61
|
+
|
|
62
|
+
# Register suspension hook for approval-required tools (no-op when a
|
|
63
|
+
# synchronous on_approval_required handler is already registered).
|
|
64
|
+
_register_suspension_hook!(chat)
|
|
65
|
+
|
|
66
|
+
# Check for cancellation immediately before the LLM call.
|
|
67
|
+
check_cancellation!(cfg, "invocation cancelled before LLM call")
|
|
68
|
+
|
|
69
|
+
# Forward the cancellation token to ParallelToolChat explicitly
|
|
70
|
+
# via the chat instance so that tool dispatch batches can observe
|
|
71
|
+
# cancellation without needing Thread.current.
|
|
72
|
+
chat.cancellation_token = cfg[:cancellation_token] if chat.respond_to?(:cancellation_token=)
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
# Route the LLM call through the configured LLMAdapter so that the
|
|
76
|
+
# blocking HTTP request runs inside BlockingAdapterPool and the
|
|
77
|
+
# adapter can be swapped without changing agent code.
|
|
78
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
79
|
+
response = adapter.complete_async(chat, user_message, config: cfg).await
|
|
80
|
+
rescue SuspendSignal => signal
|
|
81
|
+
checkpoint = Checkpoint.new(
|
|
82
|
+
thread_id: tid,
|
|
83
|
+
original_input: inp,
|
|
84
|
+
messages: chat.messages.dup,
|
|
85
|
+
pending_tool_name: signal.tool_name,
|
|
86
|
+
pending_tool_args: signal.args,
|
|
87
|
+
pending_tool_call_id: signal.tool_call_id
|
|
88
|
+
)
|
|
89
|
+
suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
|
|
90
|
+
next [suspended_result, nil]
|
|
91
|
+
ensure
|
|
92
|
+
# Clear the chat's cancellation token reference after each LLM call.
|
|
93
|
+
chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
output = response.content
|
|
97
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
98
|
+
|
|
99
|
+
# Run output guardrails before returning to the caller.
|
|
100
|
+
run_output_guardrails!(output)
|
|
101
|
+
|
|
102
|
+
result = {output: output, messages: chat.messages, usage: usage}
|
|
103
|
+
[result, usage]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|