phronomy 0.28.0 → 0.30.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 +96 -1
- data/README.md +32 -6
- data/docs/application-recipes.md +229 -0
- data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
- data/docs/architecture/agent-iteration-budget.md +81 -0
- data/docs/architecture/dx05-agent-scope.md +126 -0
- data/docs/architecture/dx05-workflow-task-state.md +134 -0
- data/docs/architecture/error-api-review.md +245 -0
- data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
- data/docs/architecture/generator-verifier-ownership.md +18 -109
- data/docs/architecture/llm-continuation-metadata.md +88 -0
- data/docs/architecture/lm-studio-output-failures.md +111 -0
- data/docs/architecture/r8-unit15.md +3 -0
- data/docs/architecture/r8-unit7.md +2 -1
- data/docs/architecture/structured-output-and-scope.md +183 -0
- data/docs/architecture/tracing.md +22 -4
- data/docs/architecture/transport-error-boundaries.md +103 -0
- data/docs/architecture/trust-acceptance-followup.md +87 -0
- data/docs/failure-reason-codes.md +45 -0
- data/docs/features.md +14 -2
- data/docs/getting-started.md +44 -43
- data/docs/migrations/r8-unit15.md +3 -0
- data/docs/ordered-event-delivery.md +54 -0
- data/docs/workflow-completion.md +92 -0
- data/gemfiles/http_minimum.gemfile +10 -0
- data/lib/phronomy/agent/admission.rb +1 -1
- data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
- data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
- data/lib/phronomy/agent/api/agent.rb +13 -0
- data/lib/phronomy/agent/async_event_api.rb +121 -88
- data/lib/phronomy/agent/base.rb +82 -44
- data/lib/phronomy/agent/before_llm_input.rb +7 -1
- data/lib/phronomy/agent/context_importer.rb +4 -0
- data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
- data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
- data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
- data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
- data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
- data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
- data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
- data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
- data/lib/phronomy/agent/execution_observation.rb +5 -5
- data/lib/phronomy/agent/filterable.rb +21 -15
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
- data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
- data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
- data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
- data/lib/phronomy/agent/output_contract.rb +64 -0
- data/lib/phronomy/agent/output_error.rb +53 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
- data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
- data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
- data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
- data/lib/phronomy/agent/runtime_input.rb +15 -4
- data/lib/phronomy/agent/saved_context_reader.rb +2 -1
- data/lib/phronomy/agent/store.rb +10 -2
- data/lib/phronomy/agent/stream_callback_error.rb +2 -2
- data/lib/phronomy/common/error.rb +65 -1
- data/lib/phronomy/common/values/immutable.rb +33 -0
- data/lib/phronomy/content_store/base.rb +7 -2
- data/lib/phronomy/context/assembly.rb +18 -22
- data/lib/phronomy/embeddings/authentication_error.rb +11 -0
- data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
- data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
- data/lib/phronomy/engine/fsm_session.rb +16 -1
- data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
- data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
- data/lib/phronomy/execution/task_result.rb +7 -2
- data/lib/phronomy/filter/filter_block_error.rb +2 -2
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
- data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
- data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
- data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
- data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
- data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
- data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
- data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
- data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
- data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
- data/lib/phronomy/generation/generator_verifier.rb +170 -85
- data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
- data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
- data/lib/phronomy/llm_adapter/message.rb +4 -0
- data/lib/phronomy/llm_adapter/request.rb +17 -2
- data/lib/phronomy/llm_adapter/response.rb +11 -4
- data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
- data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
- data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
- data/lib/phronomy/multi_agent/shared_state.rb +1 -0
- data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
- data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
- data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
- data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
- data/lib/phronomy/output_parser/parse_error.rb +10 -1
- data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
- data/lib/phronomy/storage/condition_failed_error.rb +2 -2
- data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
- data/lib/phronomy/tool/base.rb +25 -23
- data/lib/phronomy/tracing/automatic.rb +14 -8
- data/lib/phronomy/tracing/base.rb +37 -7
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/tracing/observation.rb +2 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +30 -2
- data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
- data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
- data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
- data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
- data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
- data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
- data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
- data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
- data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
- data/lib/phronomy.rb +7 -2
- data/scripts/api_snapshot.rb +15 -0
- data/scripts/check_readme_ruby.rb +24 -29
- data/scripts/check_readme_runnable.rb +92 -81
- data/scripts/migrate_generator_verifier_result.rb +18 -0
- data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
- data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
- data/sig/phronomy/agent/execution_contract.rbs +4 -3
- data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
- data/sig/phronomy/agent.rbs +36 -5
- data/sig/phronomy/embeddings/base.rbs +6 -0
- data/sig/phronomy/execution/execution.rbs +2 -2
- data/sig/phronomy/extensions.rbs +8 -2
- data/sig/phronomy/filter/filter_block_error.rbs +6 -0
- data/sig/phronomy/generation/generator_verifier.rbs +70 -0
- data/sig/phronomy/integrations.rbs +14 -0
- data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
- data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
- data/sig/phronomy/llm_adapter/values.rbs +8 -2
- data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
- data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
- data/sig/phronomy/storage/contracts.rbs +2 -2
- data/sig/phronomy/tracing/observation.rbs +1 -1
- data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
- data/sig/phronomy/workflow.rbs +4 -0
- data/sig/phronomy.rbs +6 -0
- metadata +103 -5
- data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
- data/lib/phronomy/generation/low_confidence_error.rb +0 -14
- data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
|
@@ -8,13 +8,20 @@ module Phronomy
|
|
|
8
8
|
# Completed assistant turn. Tool requests do not authorize execution.
|
|
9
9
|
# @api public
|
|
10
10
|
class Response < Message
|
|
11
|
-
attr_reader :usage
|
|
11
|
+
attr_reader :usage, :finish_reason, :refusal
|
|
12
12
|
|
|
13
13
|
# @api public
|
|
14
|
-
def initialize(content: nil, tool_calls: [], usage: Phronomy::LLMAdapter::TokenUsage.new, metadata: {}, role: :assistant
|
|
14
|
+
def initialize(content: nil, tool_calls: [], usage: Phronomy::LLMAdapter::TokenUsage.new, metadata: {}, role: :assistant,
|
|
15
|
+
finish_reason: :unknown, refusal: nil)
|
|
15
16
|
raise ArgumentError, "LLM response must be an assistant message" unless role == :assistant || role == "assistant"
|
|
16
17
|
raise ArgumentError, "LLM usage must be LLMAdapter::TokenUsage" unless usage.is_a?(Phronomy::LLMAdapter::TokenUsage)
|
|
17
18
|
@usage = usage
|
|
19
|
+
unless %w[stop tool_calls length content_filter refusal unknown].include?(finish_reason.to_s)
|
|
20
|
+
raise ArgumentError, "invalid LLM finish reason"
|
|
21
|
+
end
|
|
22
|
+
raise ArgumentError, "refusal must be a String or nil" unless refusal.nil? || refusal.is_a?(String)
|
|
23
|
+
@finish_reason = finish_reason.to_sym
|
|
24
|
+
@refusal = refusal&.dup&.freeze
|
|
18
25
|
super(role: :assistant, content: content, tool_calls: tool_calls, metadata: metadata)
|
|
19
26
|
end
|
|
20
27
|
|
|
@@ -24,12 +31,12 @@ module Phronomy
|
|
|
24
31
|
new(role: source.fetch("role", "assistant"), content: source["content"],
|
|
25
32
|
tool_calls: source.fetch("tool_calls", []).map { |call| Phronomy::Tool::CallRequest.from_h(call) },
|
|
26
33
|
usage: Phronomy::LLMAdapter::TokenUsage.new(**source.fetch("usage", {}).transform_keys(&:to_sym)),
|
|
27
|
-
metadata: source.fetch("metadata", {}))
|
|
34
|
+
metadata: source.fetch("metadata", {}), finish_reason: source.fetch("finish_reason", "unknown"), refusal: source["refusal"])
|
|
28
35
|
end
|
|
29
36
|
|
|
30
37
|
# @api public
|
|
31
38
|
def to_h
|
|
32
|
-
super.merge("usage" => usage.to_h.transform_keys(&:to_s))
|
|
39
|
+
super.merge("usage" => usage.to_h.transform_keys(&:to_s), "finish_reason" => finish_reason.to_s, "refusal" => refusal)
|
|
33
40
|
end
|
|
34
41
|
end
|
|
35
42
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module LLMAdapter
|
|
5
|
+
# The selected native schema path cannot implement the requested contract.
|
|
6
|
+
# @api public
|
|
7
|
+
class StructuredOutputUnsupportedError < Phronomy::Error
|
|
8
|
+
# @api public
|
|
9
|
+
def initialize(message = "Native structured output is unsupported")
|
|
10
|
+
super(message, code: "llm.structured_output_unsupported")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -4,6 +4,10 @@ require_relative "../common/error"
|
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
6
|
module LLMAdapter
|
|
7
|
+
# A provider communication/generation failure. Optional reason code
|
|
8
|
+
# "llm.provider_output_parse_failed" means the provider rejected its own
|
|
9
|
+
# generated output format. It is not application JSON/schema validation,
|
|
10
|
+
# does not imply a particular HTTP status, and does not authorize replay.
|
|
7
11
|
class TransportError < Phronomy::Error; end
|
|
8
12
|
end
|
|
9
13
|
end
|
|
@@ -12,7 +12,7 @@ module Phronomy
|
|
|
12
12
|
Phronomy::Persistence::StorageBoundary.call do
|
|
13
13
|
raise Phronomy::Storage::SerializationError, "create_active requires an active TeamExecution" unless execution.active?
|
|
14
14
|
record = Codec.encode_team_execution(execution)
|
|
15
|
-
admission do
|
|
15
|
+
admission(execution.team_id) do
|
|
16
16
|
@view.atomic do |bound|
|
|
17
17
|
entry = bound.records(Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS).insert(key: execution.team_execution_id.to_s,
|
|
18
18
|
revision: Integer(execution.execution_revision), attributes: attributes(execution), record: record)
|
|
@@ -42,7 +42,7 @@ module Phronomy
|
|
|
42
42
|
raise Phronomy::Storage::SerializationError, "Execution identity mismatch"
|
|
43
43
|
end
|
|
44
44
|
record = Codec.encode_team_execution(execution)
|
|
45
|
-
admission do
|
|
45
|
+
admission(execution.team_id) do
|
|
46
46
|
@view.atomic do |bound|
|
|
47
47
|
entry = bound.records(Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS).replace(key: team_execution_id.to_s,
|
|
48
48
|
expected_revision: expected, next_revision: revision, attributes: attributes(execution),
|
|
@@ -92,7 +92,7 @@ module Phronomy
|
|
|
92
92
|
conditions: [condition])
|
|
93
93
|
rescue Phronomy::Storage::ConditionFailedError => error
|
|
94
94
|
raise unless error.condition.equal?(condition)
|
|
95
|
-
raise Phronomy::
|
|
95
|
+
raise Phronomy::MultiAgent::TeamBusyError.new(error.message, team_id: team_id), cause: error
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
|
|
@@ -100,11 +100,11 @@ module Phronomy
|
|
|
100
100
|
|
|
101
101
|
def attributes(execution) = {owner: execution.team_id.to_s, active: execution.active?}
|
|
102
102
|
|
|
103
|
-
def admission
|
|
103
|
+
def admission(team_id)
|
|
104
104
|
yield
|
|
105
105
|
rescue Phronomy::Storage::UniqueConstraintError => error
|
|
106
106
|
raise unless error.resource == Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS.id && error.constraint == :one_active_owner
|
|
107
|
-
raise Phronomy::
|
|
107
|
+
raise Phronomy::MultiAgent::TeamBusyError.new(error.message, team_id: team_id), cause: error
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def decode(entry, team_execution_id, owner: nil, revision: nil, active: nil)
|
|
@@ -179,6 +179,7 @@ module Phronomy
|
|
|
179
179
|
# instrumentation.
|
|
180
180
|
instrumented_def = instrumented_definition_for(researcher_class)
|
|
181
181
|
Class.new(researcher_class) do
|
|
182
|
+
model researcher_class.model
|
|
182
183
|
agent_definition(
|
|
183
184
|
id: instrumented_def.fetch(:id),
|
|
184
185
|
version: instrumented_def.fetch(:version)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../common/error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Describes the Team itself, not a child Agent.
|
|
8
|
+
# @api public
|
|
9
|
+
class TeamBusyError < Phronomy::Error
|
|
10
|
+
attr_reader :team_id
|
|
11
|
+
|
|
12
|
+
def initialize(message = nil, team_id:)
|
|
13
|
+
@team_id = team_id.to_s.dup.freeze
|
|
14
|
+
super(message || "Team #{@team_id}: busy", code: "team.busy")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -188,11 +188,22 @@ module Phronomy
|
|
|
188
188
|
begin
|
|
189
189
|
store.transaction { |tx| tx.teams.create(intended) }
|
|
190
190
|
rescue => error
|
|
191
|
-
|
|
191
|
+
begin
|
|
192
|
+
confirmed = store.teams.load(id)
|
|
193
|
+
rescue
|
|
194
|
+
# A failed readback cannot turn an unknown create outcome into
|
|
195
|
+
# a claim that the Team is absent or safe to create again.
|
|
196
|
+
raise error, cause: error.cause
|
|
197
|
+
end
|
|
192
198
|
raise error unless confirmed.to_h == intended.to_h
|
|
193
199
|
end
|
|
194
200
|
end
|
|
195
|
-
|
|
201
|
+
begin
|
|
202
|
+
root = store.teams.load(id)
|
|
203
|
+
rescue Phronomy::Persistence::NotFoundError => error
|
|
204
|
+
raise if create || error.is_a?(TeamNotFoundError)
|
|
205
|
+
raise TeamNotFoundError.new(team_id: id), cause: error
|
|
206
|
+
end
|
|
196
207
|
unless root.team_definition_id == definition.fetch(:id) && root.team_definition_version == definition.fetch(:version)
|
|
197
208
|
raise Phronomy::ConfigurationError, "Team #{id} definition mismatch"
|
|
198
209
|
end
|
|
@@ -238,7 +249,7 @@ module Phronomy
|
|
|
238
249
|
begin
|
|
239
250
|
persistence.transaction do |tx, scope|
|
|
240
251
|
root = tx.teams.load(team_id)
|
|
241
|
-
raise
|
|
252
|
+
raise TeamBusyError.new("Team #{team_id} has an active run; use resume", team_id: team_id) unless tx.team_executions.list_active(team_id).empty?
|
|
242
253
|
intended = TeamExecution.new(team_execution_id: id, team_id: team_id,
|
|
243
254
|
execution_revision: 0, status: "active", phase: "coordinator", input_ref: tx.contents.put_text(input),
|
|
244
255
|
coordinator: {"agent_id" => SecureRandom.uuid, "execution_id" => SecureRandom.uuid, "state" => "reserved"},
|
|
@@ -341,7 +352,7 @@ module Phronomy
|
|
|
341
352
|
output = Phronomy::Values::Serializable.convert(aggregate ? aggregate.call(values) : values, unsupported_message: "Team result is not canonically serializable")
|
|
342
353
|
Phronomy::CanonicalJSON.dump(output)
|
|
343
354
|
rescue => error
|
|
344
|
-
return terminal_value(finish_error(id,
|
|
355
|
+
return terminal_value(finish_error(id, Phronomy::Error.diagnostic(error)))
|
|
345
356
|
end
|
|
346
357
|
# Aggregate may be replayed only until the canonical outcome commits.
|
|
347
358
|
completed = update(id) do |fresh, tx, scope|
|
|
@@ -465,7 +476,7 @@ module Phronomy
|
|
|
465
476
|
raise Phronomy::CancellationError, "Team run #{execution.team_execution_id} cancelled" if execution.status == "cancelled"
|
|
466
477
|
if execution.error_ref
|
|
467
478
|
failure = persistence.contents.fetch_json(execution.error_ref)
|
|
468
|
-
raise Phronomy::Error
|
|
479
|
+
raise Phronomy::Error.from_diagnostic(failure)
|
|
469
480
|
end
|
|
470
481
|
persistence.contents.fetch_json(execution.result_ref)
|
|
471
482
|
end
|
|
@@ -514,7 +525,7 @@ module Phronomy
|
|
|
514
525
|
Phronomy::Execution.submit(on_full: :raise) do
|
|
515
526
|
team.send(:apply_operation, run_id, key, operation, arguments)
|
|
516
527
|
rescue OperationRejected => error
|
|
517
|
-
raise Phronomy::ConfigurationError, error.
|
|
528
|
+
raise Phronomy::ConfigurationError.new(error.message, code: error.code)
|
|
518
529
|
rescue Phronomy::CancellationError
|
|
519
530
|
raise
|
|
520
531
|
rescue => error
|
|
@@ -551,7 +562,9 @@ module Phronomy
|
|
|
551
562
|
name = entry.name
|
|
552
563
|
values = entry.arguments
|
|
553
564
|
if name == "enqueue_task"
|
|
554
|
-
|
|
565
|
+
if metadata["finalized"]
|
|
566
|
+
raise OperationRejected.new("Cannot enqueue after finalize", code: "team.enqueue_after_finalize")
|
|
567
|
+
end
|
|
555
568
|
task = {"id" => Digest::SHA256.hexdigest(entry_id)[0, 32], "description" => values.fetch("description"), "metadata" => values["metadata"]}
|
|
556
569
|
tasks << task
|
|
557
570
|
output = "Task ##{tasks.length} enqueued: #{task.fetch("description")}"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/errors"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Describes the Team itself, not a child Agent.
|
|
8
|
+
# @api public
|
|
9
|
+
class TeamNotFoundError < Phronomy::Persistence::NotFoundError
|
|
10
|
+
attr_reader :team_id
|
|
11
|
+
|
|
12
|
+
def initialize(message = nil, team_id:)
|
|
13
|
+
@team_id = team_id.to_s.dup.freeze
|
|
14
|
+
super(message || "Team #{@team_id}: not_found", code: "team.not_found")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "json_schemer"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "../common/values/immutable"
|
|
7
|
+
|
|
8
|
+
module Phronomy
|
|
9
|
+
module OutputParser
|
|
10
|
+
# Strict JSON decoding and validation, without extraction or value coercion.
|
|
11
|
+
# @api public
|
|
12
|
+
class JsonSchemaParser < Base
|
|
13
|
+
KEYWORDS = %w[$schema $defs $ref title description type enum const anyOf
|
|
14
|
+
properties required additionalProperties items minItems maxItems minimum
|
|
15
|
+
maximum exclusiveMinimum exclusiveMaximum multipleOf minLength maxLength pattern].freeze
|
|
16
|
+
private_constant :KEYWORDS
|
|
17
|
+
|
|
18
|
+
# @api public
|
|
19
|
+
attr_reader :json_schema
|
|
20
|
+
|
|
21
|
+
# @api public
|
|
22
|
+
def initialize(schema:)
|
|
23
|
+
@json_schema = Phronomy::Values::Immutable.json(schema)
|
|
24
|
+
unless @json_schema.is_a?(Hash) && @json_schema["type"] == "object"
|
|
25
|
+
raise ArgumentError, "output schema must declare a root object"
|
|
26
|
+
end
|
|
27
|
+
validate_node!(@json_schema, [])
|
|
28
|
+
options = {ref_resolver: ->(_uri) { raise ArgumentError, "external output schema reference" }}
|
|
29
|
+
unless JSONSchemer.valid_schema?(@json_schema, **options)
|
|
30
|
+
raise ArgumentError, "invalid output JSON Schema"
|
|
31
|
+
end
|
|
32
|
+
@validator = JSONSchemer.schema(@json_schema, **options)
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @api public
|
|
37
|
+
def parse(text)
|
|
38
|
+
value = decode(text)
|
|
39
|
+
violations = @validator.validate(value).map do |error|
|
|
40
|
+
{"pointer" => error.fetch("data_pointer", ""),
|
|
41
|
+
"schema_pointer" => error.fetch("schema_pointer", ""),
|
|
42
|
+
"keyword" => error.fetch("type", "schema")}
|
|
43
|
+
end
|
|
44
|
+
unless violations.empty?
|
|
45
|
+
raise Phronomy::ParseError.new("Output does not satisfy its JSON schema",
|
|
46
|
+
code: "output_parser.schema_mismatch", details: violations)
|
|
47
|
+
end
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def decode(text)
|
|
54
|
+
raise JSON::ParserError, "output must be JSON text" unless text.is_a?(String)
|
|
55
|
+
Phronomy::Values::Immutable.json(JSON.parse(text, allow_duplicate_key: false, allow_nan: false, allow_comments: false, allow_invalid_escape: false, allow_control_characters: false))
|
|
56
|
+
rescue JSON::ParserError, ArgumentError
|
|
57
|
+
raise Phronomy::ParseError.new("Output is not strict JSON",
|
|
58
|
+
code: "output_parser.invalid_json", details: [{"pointer" => "", "keyword" => "json"}]), cause: nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_node!(node, ancestors)
|
|
62
|
+
return if node == true || node == false
|
|
63
|
+
raise ArgumentError, "invalid output schema node" unless node.is_a?(Hash)
|
|
64
|
+
raise ArgumentError, "cyclic output schema reference" if ancestors.include?(node.object_id)
|
|
65
|
+
ancestors += [node.object_id]
|
|
66
|
+
unknown = node.keys - KEYWORDS
|
|
67
|
+
raise ArgumentError, "unsupported output schema keywords: #{unknown.join(", ")}" unless unknown.empty?
|
|
68
|
+
if node.key?("$schema") && node["$schema"] != "https://json-schema.org/draft/2020-12/schema"
|
|
69
|
+
raise ArgumentError, "output schemas require JSON Schema 2020-12"
|
|
70
|
+
end
|
|
71
|
+
if node.key?("pattern")
|
|
72
|
+
raise ArgumentError, "pattern must be a String" unless node["pattern"].is_a?(String)
|
|
73
|
+
begin
|
|
74
|
+
Regexp.new(node["pattern"])
|
|
75
|
+
rescue RegexpError
|
|
76
|
+
raise ArgumentError, "invalid output schema pattern"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
%w[properties $defs].each do |key|
|
|
80
|
+
next unless node.key?(key)
|
|
81
|
+
raise ArgumentError, "#{key} must be an object" unless node[key].is_a?(Hash)
|
|
82
|
+
node[key].each_value { |child| validate_node!(child, ancestors) }
|
|
83
|
+
end
|
|
84
|
+
if node.key?("anyOf")
|
|
85
|
+
raise ArgumentError, "anyOf must be an array" unless node["anyOf"].is_a?(Array)
|
|
86
|
+
node["anyOf"].each { |child| validate_node!(child, ancestors) }
|
|
87
|
+
end
|
|
88
|
+
%w[items additionalProperties].each do |key|
|
|
89
|
+
validate_node!(node[key], ancestors) if node.key?(key)
|
|
90
|
+
end
|
|
91
|
+
validate_node!(resolve_reference(node["$ref"]), ancestors) if node.key?("$ref")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_reference(reference)
|
|
95
|
+
valid = reference.is_a?(String) && (reference == "#" || reference.start_with?("#/"))
|
|
96
|
+
unless valid
|
|
97
|
+
raise ArgumentError, "output schemas support local JSON Pointer references only"
|
|
98
|
+
end
|
|
99
|
+
URI::DEFAULT_PARSER.unescape(reference.delete_prefix("#")).split("/", -1).drop(1).reduce(@json_schema) do |node, token|
|
|
100
|
+
raise ArgumentError, "invalid output schema JSON Pointer" if token.match?(/~(?![01])/)
|
|
101
|
+
key = token.gsub("~1", "/").gsub("~0", "~")
|
|
102
|
+
if node.is_a?(Hash) && node.key?(key)
|
|
103
|
+
node.fetch(key)
|
|
104
|
+
elsif node.is_a?(Array) && key.match?(/\A(?:0|[1-9][0-9]*)\z/) && key.to_i < node.length
|
|
105
|
+
node.fetch(key.to_i)
|
|
106
|
+
else
|
|
107
|
+
raise ArgumentError, "output schema reference does not exist"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -3,5 +3,14 @@
|
|
|
3
3
|
require_relative "../common/error"
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
|
-
class ParseError < Error
|
|
6
|
+
class ParseError < Error
|
|
7
|
+
# @api public
|
|
8
|
+
attr_reader :details
|
|
9
|
+
|
|
10
|
+
# @api public
|
|
11
|
+
def initialize(message = nil, code: nil, details: [])
|
|
12
|
+
@details = Phronomy::Values::Immutable.json(details)
|
|
13
|
+
super(message, code: code)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
7
16
|
end
|
|
@@ -9,3 +9,7 @@ Phronomy::Agent::DefaultPersistence.install_factory(
|
|
|
9
9
|
)
|
|
10
10
|
|
|
11
11
|
Phronomy::Agent::ExecutionEnvironment.install_provider(-> { Phronomy::Agent::EngineEnvironment.new })
|
|
12
|
+
|
|
13
|
+
Phronomy::Agent::OutputContract.install_factory(
|
|
14
|
+
->(schema) { Phronomy::OutputParser::JsonSchemaParser.new(schema: schema) }
|
|
15
|
+
)
|
|
@@ -7,9 +7,9 @@ module Phronomy
|
|
|
7
7
|
class ConditionFailedError < ConflictError
|
|
8
8
|
attr_reader :condition
|
|
9
9
|
# @api public
|
|
10
|
-
def initialize(condition)
|
|
10
|
+
def initialize(condition, code: nil)
|
|
11
11
|
@condition = condition
|
|
12
|
-
super("storage condition failed: #{condition.class.name}")
|
|
12
|
+
super("storage condition failed: #{condition.class.name}", code: code)
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -7,10 +7,10 @@ module Phronomy
|
|
|
7
7
|
class UniqueConstraintError < ConflictError
|
|
8
8
|
attr_reader :resource, :constraint
|
|
9
9
|
# @api public
|
|
10
|
-
def initialize(resource:, constraint:)
|
|
10
|
+
def initialize(resource:, constraint:, code: nil)
|
|
11
11
|
@resource = resource.id
|
|
12
12
|
@constraint = constraint
|
|
13
|
-
super("unique constraint #{resource.id}.#{constraint} failed")
|
|
13
|
+
super("unique constraint #{resource.id}.#{constraint} failed", code: code)
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -84,7 +84,7 @@ RSpec.shared_examples "a Team execution repository" do
|
|
|
84
84
|
it "atomically admits at most one active run per Team" do
|
|
85
85
|
stores.multi_agent.team_executions.create_active(coordination_run)
|
|
86
86
|
other = coordination_run.with(team_execution_id: SecureRandom.uuid, execution_revision: 0)
|
|
87
|
-
expect { stores.multi_agent.team_executions.create_active(other) }.to raise_error(Phronomy::
|
|
87
|
+
expect { stores.multi_agent.team_executions.create_active(other) }.to raise_error(Phronomy::MultiAgent::TeamBusyError)
|
|
88
88
|
expect(stores.multi_agent.team_executions.list_active(coordination_team.team_id).map(&:team_execution_id)).to eq([coordination_run.team_execution_id])
|
|
89
89
|
end
|
|
90
90
|
|
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -383,8 +383,9 @@ module Phronomy
|
|
|
383
383
|
if schema_error
|
|
384
384
|
case self.class.on_schema_error
|
|
385
385
|
when :raise
|
|
386
|
-
raise Phronomy::ToolError
|
|
387
|
-
"#{self.class.name} schema error: #{schema_error}"
|
|
386
|
+
raise Phronomy::ToolError.new(
|
|
387
|
+
"#{self.class.name} schema error: #{schema_error}", code: "tool.invalid_arguments"
|
|
388
|
+
)
|
|
388
389
|
else
|
|
389
390
|
return "Schema validation failed: #{schema_error}"
|
|
390
391
|
end
|
|
@@ -399,17 +400,12 @@ module Phronomy
|
|
|
399
400
|
raise
|
|
400
401
|
rescue => error
|
|
401
402
|
if self.class.on_error == :suppress
|
|
402
|
-
|
|
403
|
-
"#{error.class}: #{error.message}"
|
|
404
|
-
if Phronomy::RuntimeSettings.current.logger
|
|
405
|
-
Phronomy::RuntimeSettings.current.logger.warn(msg)
|
|
406
|
-
else
|
|
407
|
-
warn msg
|
|
408
|
-
end
|
|
403
|
+
report_suppressed_error(error)
|
|
409
404
|
"Tool error suppressed: #{error.message}"
|
|
410
405
|
else
|
|
411
|
-
raise Phronomy::ToolError
|
|
412
|
-
"#{self.class.name} execution failed: #{error.message}"
|
|
406
|
+
raise Phronomy::ToolError.new(
|
|
407
|
+
"#{self.class.name} execution failed: #{error.message}", code: "tool.execution_failed"
|
|
408
|
+
), cause: error
|
|
413
409
|
end
|
|
414
410
|
end
|
|
415
411
|
|
|
@@ -462,7 +458,9 @@ module Phronomy
|
|
|
462
458
|
if schema_error
|
|
463
459
|
return Phronomy::AsyncOperation.capture(name: "#{operation_name}-schema") do
|
|
464
460
|
if self.class.on_schema_error == :raise
|
|
465
|
-
raise Phronomy::ToolError
|
|
461
|
+
raise Phronomy::ToolError.new(
|
|
462
|
+
"#{self.class.name} schema error: #{schema_error}", code: "tool.invalid_arguments"
|
|
463
|
+
)
|
|
466
464
|
end
|
|
467
465
|
"Schema validation failed: #{schema_error}"
|
|
468
466
|
end
|
|
@@ -472,8 +470,9 @@ module Phronomy
|
|
|
472
470
|
transform: method(:truncate_result_if_needed)) do
|
|
473
471
|
source = yield(validated_args || {})
|
|
474
472
|
unless source.respond_to?(:on_complete)
|
|
475
|
-
raise Phronomy::ToolError
|
|
476
|
-
"#{self.class.name} asynchronous execution must return a completion handle"
|
|
473
|
+
raise Phronomy::ToolError.new(
|
|
474
|
+
"#{self.class.name} asynchronous execution must return a completion handle", code: "tool.invalid_async_result"
|
|
475
|
+
)
|
|
477
476
|
end
|
|
478
477
|
source
|
|
479
478
|
end
|
|
@@ -488,23 +487,26 @@ module Phronomy
|
|
|
488
487
|
end
|
|
489
488
|
|
|
490
489
|
if self.class.on_error == :suppress
|
|
491
|
-
|
|
492
|
-
"#{error.class}: #{error.message}"
|
|
493
|
-
if Phronomy::RuntimeSettings.current.logger
|
|
494
|
-
Phronomy::RuntimeSettings.current.logger.warn(message)
|
|
495
|
-
else
|
|
496
|
-
warn message
|
|
497
|
-
end
|
|
490
|
+
report_suppressed_error(error)
|
|
498
491
|
"Tool error suppressed: #{error.message}"
|
|
499
492
|
else
|
|
500
493
|
wrapped = Phronomy::ToolError.new(
|
|
501
|
-
"#{self.class.name} execution failed: #{error.message}"
|
|
494
|
+
"#{self.class.name} execution failed: #{error.message}", code: "tool.execution_failed"
|
|
502
495
|
)
|
|
503
496
|
wrapped.set_backtrace(error.backtrace)
|
|
504
|
-
raise wrapped
|
|
497
|
+
raise wrapped, cause: error
|
|
505
498
|
end
|
|
506
499
|
end
|
|
507
500
|
|
|
501
|
+
def report_suppressed_error(error)
|
|
502
|
+
message = "[Phronomy] Tool #{self.class.name} suppressed error: #{error.class}: #{error.message}"
|
|
503
|
+
logger = Phronomy::RuntimeSettings.current.logger
|
|
504
|
+
logger ? logger.warn(message) : warn(message)
|
|
505
|
+
rescue
|
|
506
|
+
# A diagnostic failure must not change the selected suppression policy.
|
|
507
|
+
nil
|
|
508
|
+
end
|
|
509
|
+
|
|
508
510
|
def execute_accepts_cancellation_token?
|
|
509
511
|
method(:execute).parameters.any? do |type, name|
|
|
510
512
|
name == :cancellation_token && %i[key keyreq].include?(type)
|
|
@@ -65,23 +65,23 @@ module Phronomy
|
|
|
65
65
|
# simplecov:enable
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def observe_task(task, name, input: nil, **metadata)
|
|
68
|
+
def observe_task(task, name, input: nil, usage: nil, **metadata)
|
|
69
69
|
handle = start(name, input: input, **metadata)
|
|
70
70
|
task.on_complete do |value, error|
|
|
71
71
|
finish(
|
|
72
72
|
handle,
|
|
73
73
|
output: value,
|
|
74
|
-
usage:
|
|
74
|
+
usage: observed_usage(usage, value, handle),
|
|
75
75
|
error: error
|
|
76
76
|
)
|
|
77
77
|
end
|
|
78
78
|
task
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
def trace(name, input: nil, **metadata)
|
|
81
|
+
def trace(name, input: nil, usage: nil, **metadata)
|
|
82
82
|
handle = start(name, input: input, **metadata)
|
|
83
83
|
result = yield
|
|
84
|
-
finish(handle, output: result, usage:
|
|
84
|
+
finish(handle, output: result, usage: observed_usage(usage, result, handle))
|
|
85
85
|
result
|
|
86
86
|
rescue => operation_error
|
|
87
87
|
finish(handle, error: operation_error) if defined?(handle)
|
|
@@ -122,12 +122,18 @@ module Phronomy
|
|
|
122
122
|
end
|
|
123
123
|
private_class_method :traced_error
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
# The owner of the observed result supplies its projection. Tracing never
|
|
126
|
+
# interprets Agent results or LLM value objects. Projection is diagnostic:
|
|
127
|
+
# its failure must not replace the completed operation's result/error.
|
|
128
|
+
def observed_usage(project, value, handle)
|
|
129
|
+
return nil unless handle && project
|
|
127
130
|
|
|
128
|
-
value
|
|
131
|
+
project.call(value)
|
|
132
|
+
rescue => error
|
|
133
|
+
report_failure(:usage, handle.name, error, include_message: handle.trace_pii)
|
|
134
|
+
nil
|
|
129
135
|
end
|
|
130
|
-
private_class_method :
|
|
136
|
+
private_class_method :observed_usage
|
|
131
137
|
|
|
132
138
|
def submit_finish(handle, attributes)
|
|
133
139
|
Phronomy::Blocking.call_async do
|
|
@@ -34,15 +34,44 @@ module Phronomy
|
|
|
34
34
|
# @return [Object] the block's return value
|
|
35
35
|
# @api public
|
|
36
36
|
def trace(name, input: nil, **meta)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
Base.trace_with(self, name, attributes: {input: input, **meta}) { |span| yield span }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Use the public start/finish SPI, including duck-typed tracers.
|
|
41
|
+
# Start failures do not finish; finish is attempted once after execution.
|
|
42
|
+
# A secondary finish/reporting failure cannot replace the block's error.
|
|
43
|
+
# Set record_output: false for metadata-only operations.
|
|
44
|
+
# @api public
|
|
45
|
+
def self.trace_with(tracer, name, record_output: true, attributes: {})
|
|
46
|
+
span = tracer.start_span(name, **attributes)
|
|
47
|
+
begin
|
|
48
|
+
result, usage = yield span
|
|
49
|
+
rescue => primary
|
|
50
|
+
begin
|
|
51
|
+
tracer.finish_span(span, error: primary)
|
|
52
|
+
rescue => secondary
|
|
53
|
+
report_secondary(secondary)
|
|
54
|
+
end
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
if record_output
|
|
58
|
+
tracer.finish_span(span, output: result, usage: usage)
|
|
59
|
+
else
|
|
60
|
+
tracer.finish_span(span)
|
|
61
|
+
end
|
|
40
62
|
result
|
|
41
|
-
rescue => e
|
|
42
|
-
finish_span(span, error: e)
|
|
43
|
-
raise
|
|
44
63
|
end
|
|
45
64
|
|
|
65
|
+
def self.report_secondary(error)
|
|
66
|
+
Phronomy::RuntimeSettings.current.logger&.warn(
|
|
67
|
+
"[Phronomy::Tracing] secondary finish failure: #{error.class}: #{error.message}"
|
|
68
|
+
)
|
|
69
|
+
rescue
|
|
70
|
+
# Diagnostics are best effort; preserve the primary business failure.
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
private_class_method :report_secondary
|
|
74
|
+
|
|
46
75
|
# Start a new span. Must return an object that will be passed to #finish_span.
|
|
47
76
|
#
|
|
48
77
|
# @param name [String]
|
|
@@ -57,7 +86,8 @@ module Phronomy
|
|
|
57
86
|
#
|
|
58
87
|
# @param span [Object] the span returned by #start_span
|
|
59
88
|
# @param output [Object, nil] successful output value
|
|
60
|
-
# @param usage [
|
|
89
|
+
# @param usage [Hash{Symbol => Integer, nil}, nil] observed token counts;
|
|
90
|
+
# :input, :output, :cached, :cache_creation. The caller projects its domain value.
|
|
61
91
|
# @param error [Exception, nil] exception if the block raised
|
|
62
92
|
# @api public
|
|
63
93
|
def finish_span(span, output: nil, usage: nil, error: nil)
|
|
@@ -71,9 +71,9 @@ module Phronomy
|
|
|
71
71
|
body[:statusMessage] = error.message if error
|
|
72
72
|
if usage
|
|
73
73
|
body[:usage] = {
|
|
74
|
-
input: usage
|
|
75
|
-
output: usage
|
|
76
|
-
total: (usage
|
|
74
|
+
input: usage[:input],
|
|
75
|
+
output: usage[:output],
|
|
76
|
+
total: (usage[:input] || 0) + (usage[:output] || 0)
|
|
77
77
|
}
|
|
78
78
|
end
|
|
79
79
|
ingest([{type: "span-create", body: body}])
|
|
@@ -5,7 +5,8 @@ module Phronomy
|
|
|
5
5
|
module Observation
|
|
6
6
|
# Convenience wrapper that delegates to the global tracer.
|
|
7
7
|
# Yields a span; the block must return [result, usage] where usage is a
|
|
8
|
-
#
|
|
8
|
+
# Hash of observed token counts or nil. Returns only the result value.
|
|
9
|
+
# Domain objects must be projected by the caller before entering Tracing.
|
|
9
10
|
#
|
|
10
11
|
# When +trace_pii+ is disabled, both the input and the output (LLM response,
|
|
11
12
|
# tool result) are replaced with the literal string "[REDACTED]" before being
|