agentic 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.agentic.yml +2 -0
- data/.architecture/decisions/ArchitecturalFeatureBuilder.md +136 -0
- data/.architecture/decisions/ArchitectureConsiderations.md +200 -0
- data/.architecture/decisions/adr_001_observer_pattern_implementation.md +196 -0
- data/.architecture/decisions/adr_002_plan_orchestrator.md +320 -0
- data/.architecture/decisions/adr_003_plan_orchestrator_interface.md +179 -0
- data/.architecture/decisions/adrs/ADR-001-dependency-management.md +147 -0
- data/.architecture/decisions/adrs/ADR-002-system-boundaries.md +162 -0
- data/.architecture/decisions/adrs/ADR-003-content-safety.md +158 -0
- data/.architecture/decisions/adrs/ADR-004-agent-permissions.md +161 -0
- data/.architecture/decisions/adrs/ADR-005-adaptation-engine.md +127 -0
- data/.architecture/decisions/adrs/ADR-006-extension-system.md +273 -0
- data/.architecture/decisions/adrs/ADR-007-learning-system.md +156 -0
- data/.architecture/decisions/adrs/ADR-008-prompt-generation.md +325 -0
- data/.architecture/decisions/adrs/ADR-009-task-failure-handling.md +353 -0
- data/.architecture/decisions/adrs/ADR-010-task-input-handling.md +251 -0
- data/.architecture/decisions/adrs/ADR-011-task-observable-pattern.md +391 -0
- data/.architecture/decisions/adrs/ADR-012-task-output-handling.md +205 -0
- data/.architecture/decisions/adrs/ADR-013-architecture-alignment.md +211 -0
- data/.architecture/decisions/adrs/ADR-014-agent-capability-registry.md +80 -0
- data/.architecture/decisions/adrs/ADR-015-persistent-agent-store.md +100 -0
- data/.architecture/decisions/adrs/ADR-016-agent-assembly-engine.md +117 -0
- data/.architecture/decisions/adrs/ADR-017-streaming-observability.md +171 -0
- data/.architecture/decisions/capability_tools_distinction.md +150 -0
- data/.architecture/decisions/cli_command_structure.md +61 -0
- data/.architecture/implementation/agent_self_assembly_implementation.md +267 -0
- data/.architecture/implementation/agent_self_assembly_summary.md +138 -0
- data/.architecture/members.yml +187 -0
- data/.architecture/planning/self_implementation_exercise.md +295 -0
- data/.architecture/planning/session_compaction_rule.md +43 -0
- data/.architecture/planning/streaming_observability_feature.md +223 -0
- data/.architecture/principles.md +151 -0
- data/.architecture/recalibration/0-2-0.md +92 -0
- data/.architecture/recalibration/agent_self_assembly.md +238 -0
- data/.architecture/recalibration/cli_command_structure.md +91 -0
- data/.architecture/recalibration/implementation_roadmap_0-2-0.md +301 -0
- data/.architecture/recalibration/progress_tracking_0-2-0.md +114 -0
- data/.architecture/recalibration_process.md +127 -0
- data/.architecture/reviews/0-2-0.md +181 -0
- data/.architecture/reviews/cli_command_duplication.md +98 -0
- data/.architecture/templates/adr.md +105 -0
- data/.architecture/templates/implementation_roadmap.md +125 -0
- data/.architecture/templates/progress_tracking.md +89 -0
- data/.architecture/templates/recalibration_plan.md +70 -0
- data/.architecture/templates/version_comparison.md +124 -0
- data/.claude/settings.local.json +13 -0
- data/.claude-sessions/001-task-class-architecture-implementation.md +129 -0
- data/.claude-sessions/002-plan-orchestrator-interface-review.md +105 -0
- data/.claude-sessions/architecture-governance-implementation.md +37 -0
- data/.claude-sessions/architecture-review-session.md +27 -0
- data/ArchitecturalFeatureBuilder.md +136 -0
- data/ArchitectureConsiderations.md +229 -0
- data/CHANGELOG.md +57 -2
- data/CLAUDE.md +111 -0
- data/CONTRIBUTING.md +286 -0
- data/MAINTAINING.md +301 -0
- data/README.md +582 -28
- data/docs/agent_capabilities_api.md +259 -0
- data/docs/artifact_extension_points.md +757 -0
- data/docs/artifact_generation_architecture.md +323 -0
- data/docs/artifact_implementation_plan.md +596 -0
- data/docs/artifact_integration_points.md +345 -0
- data/docs/artifact_verification_strategies.md +581 -0
- data/docs/streaming_observability_architecture.md +510 -0
- data/exe/agentic +6 -1
- data/lefthook.yml +5 -0
- data/lib/agentic/adaptation_engine.rb +124 -0
- data/lib/agentic/agent.rb +181 -4
- data/lib/agentic/agent_assembly_engine.rb +442 -0
- data/lib/agentic/agent_capability_registry.rb +260 -0
- data/lib/agentic/agent_config.rb +63 -0
- data/lib/agentic/agent_specification.rb +46 -0
- data/lib/agentic/capabilities/examples.rb +530 -0
- data/lib/agentic/capabilities.rb +14 -0
- data/lib/agentic/capability_provider.rb +146 -0
- data/lib/agentic/capability_specification.rb +118 -0
- data/lib/agentic/cli/agent.rb +31 -0
- data/lib/agentic/cli/capabilities.rb +191 -0
- data/lib/agentic/cli/config.rb +134 -0
- data/lib/agentic/cli/execution_observer.rb +796 -0
- data/lib/agentic/cli.rb +1068 -0
- data/lib/agentic/default_agent_provider.rb +35 -0
- data/lib/agentic/errors/llm_error.rb +184 -0
- data/lib/agentic/execution_plan.rb +53 -0
- data/lib/agentic/execution_result.rb +91 -0
- data/lib/agentic/expected_answer_format.rb +46 -0
- data/lib/agentic/extension/domain_adapter.rb +109 -0
- data/lib/agentic/extension/plugin_manager.rb +163 -0
- data/lib/agentic/extension/protocol_handler.rb +116 -0
- data/lib/agentic/extension.rb +45 -0
- data/lib/agentic/factory_methods.rb +9 -1
- data/lib/agentic/generation_stats.rb +61 -0
- data/lib/agentic/learning/README.md +84 -0
- data/lib/agentic/learning/capability_optimizer.rb +613 -0
- data/lib/agentic/learning/execution_history_store.rb +251 -0
- data/lib/agentic/learning/pattern_recognizer.rb +500 -0
- data/lib/agentic/learning/strategy_optimizer.rb +706 -0
- data/lib/agentic/learning.rb +131 -0
- data/lib/agentic/llm_assisted_composition_strategy.rb +188 -0
- data/lib/agentic/llm_client.rb +215 -15
- data/lib/agentic/llm_config.rb +65 -1
- data/lib/agentic/llm_response.rb +163 -0
- data/lib/agentic/logger.rb +1 -1
- data/lib/agentic/observable.rb +51 -0
- data/lib/agentic/persistent_agent_store.rb +385 -0
- data/lib/agentic/plan_execution_result.rb +129 -0
- data/lib/agentic/plan_orchestrator.rb +464 -0
- data/lib/agentic/plan_orchestrator_config.rb +57 -0
- data/lib/agentic/retry_config.rb +63 -0
- data/lib/agentic/retry_handler.rb +125 -0
- data/lib/agentic/structured_outputs.rb +1 -1
- data/lib/agentic/task.rb +193 -0
- data/lib/agentic/task_definition.rb +39 -0
- data/lib/agentic/task_execution_result.rb +92 -0
- data/lib/agentic/task_failure.rb +66 -0
- data/lib/agentic/task_output_schemas.rb +112 -0
- data/lib/agentic/task_planner.rb +54 -19
- data/lib/agentic/task_result.rb +48 -0
- data/lib/agentic/ui.rb +244 -0
- data/lib/agentic/verification/critic_framework.rb +116 -0
- data/lib/agentic/verification/llm_verification_strategy.rb +60 -0
- data/lib/agentic/verification/schema_verification_strategy.rb +47 -0
- data/lib/agentic/verification/verification_hub.rb +62 -0
- data/lib/agentic/verification/verification_result.rb +50 -0
- data/lib/agentic/verification/verification_strategy.rb +26 -0
- data/lib/agentic/version.rb +1 -1
- data/lib/agentic.rb +74 -2
- data/plugins/README.md +41 -0
- metadata +245 -6
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "task_execution_result"
|
4
|
+
|
5
|
+
module Agentic
|
6
|
+
# Value object representing the execution result of a plan
|
7
|
+
class PlanExecutionResult
|
8
|
+
# @return [String] The unique identifier for the plan
|
9
|
+
attr_reader :plan_id
|
10
|
+
|
11
|
+
# @return [Symbol] The overall status of the plan (:completed, :in_progress, :partial_failure)
|
12
|
+
attr_reader :status
|
13
|
+
|
14
|
+
# @return [Float] The execution time in seconds
|
15
|
+
attr_reader :execution_time
|
16
|
+
|
17
|
+
# @return [Hash] Map of task ids to serialized Task objects
|
18
|
+
attr_reader :tasks
|
19
|
+
|
20
|
+
# @return [Hash<String, TaskExecutionResult>] The execution results for each task
|
21
|
+
attr_reader :results
|
22
|
+
|
23
|
+
# Initializes a new plan execution result
|
24
|
+
# @param plan_id [String] The unique identifier for the plan
|
25
|
+
# @param status [Symbol] The overall status of the plan
|
26
|
+
# @param execution_time [Float] The execution time in seconds
|
27
|
+
# @param tasks [Hash] Map of task ids to serialized Task objects
|
28
|
+
# @param results [Hash] Map of task ids to raw execution results
|
29
|
+
def initialize(plan_id:, status:, execution_time:, tasks:, results:)
|
30
|
+
@plan_id = plan_id
|
31
|
+
@status = status
|
32
|
+
@execution_time = execution_time
|
33
|
+
@tasks = tasks
|
34
|
+
@results = convert_raw_results(results)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Creates a plan execution result from a hash
|
38
|
+
# @param hash [Hash] The hash representation of a plan execution result
|
39
|
+
# @return [PlanExecutionResult] A plan execution result
|
40
|
+
def self.from_hash(hash)
|
41
|
+
new(
|
42
|
+
plan_id: hash[:plan_id],
|
43
|
+
status: hash[:status],
|
44
|
+
execution_time: hash[:execution_time],
|
45
|
+
tasks: hash[:tasks],
|
46
|
+
results: hash[:results]
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Checks if the plan execution was successful
|
51
|
+
# @return [Boolean] True if successful, false otherwise
|
52
|
+
def successful?
|
53
|
+
@status == :completed
|
54
|
+
end
|
55
|
+
|
56
|
+
# Checks if the plan execution failed partially
|
57
|
+
# @return [Boolean] True if partially failed, false otherwise
|
58
|
+
def partial_failure?
|
59
|
+
@status == :partial_failure
|
60
|
+
end
|
61
|
+
|
62
|
+
# Checks if the plan execution is still in progress
|
63
|
+
# @return [Boolean] True if in progress, false otherwise
|
64
|
+
def in_progress?
|
65
|
+
@status == :in_progress
|
66
|
+
end
|
67
|
+
|
68
|
+
# Gets the result for a specific task
|
69
|
+
# @param task_id [String] The ID of the task
|
70
|
+
# @return [TaskExecutionResult, nil] The execution result for the task, or nil if not found
|
71
|
+
def task_result(task_id)
|
72
|
+
@results[task_id]
|
73
|
+
end
|
74
|
+
|
75
|
+
# Gets the serialized task data for a specific task
|
76
|
+
# @param task_id [String] The ID of the task
|
77
|
+
# @return [Hash, nil] The serialized task data, or nil if not found
|
78
|
+
def task_data(task_id)
|
79
|
+
@tasks[task_id]
|
80
|
+
end
|
81
|
+
|
82
|
+
# Gets the number of completed tasks
|
83
|
+
# @return [Integer] The number of completed tasks
|
84
|
+
def completed_tasks_count
|
85
|
+
@results.count { |_, result| result.successful? }
|
86
|
+
end
|
87
|
+
|
88
|
+
# Gets the number of failed tasks
|
89
|
+
# @return [Integer] The number of failed tasks
|
90
|
+
def failed_tasks_count
|
91
|
+
@results.count { |_, result| result.failed? }
|
92
|
+
end
|
93
|
+
|
94
|
+
# Gets the successful task results
|
95
|
+
# @return [Hash<String, TaskExecutionResult>] The successful task results
|
96
|
+
def successful_task_results
|
97
|
+
@results.select { |_, result| result.successful? }
|
98
|
+
end
|
99
|
+
|
100
|
+
# Gets the failed task results
|
101
|
+
# @return [Hash<String, TaskExecutionResult>] The failed task results
|
102
|
+
def failed_task_results
|
103
|
+
@results.select { |_, result| result.failed? }
|
104
|
+
end
|
105
|
+
|
106
|
+
# Returns a hash representation of the plan execution result
|
107
|
+
# @return [Hash] The plan execution result as a hash
|
108
|
+
def to_h
|
109
|
+
{
|
110
|
+
plan_id: @plan_id,
|
111
|
+
status: @status,
|
112
|
+
execution_time: @execution_time,
|
113
|
+
tasks: @tasks,
|
114
|
+
results: @results.transform_values(&:to_h)
|
115
|
+
}
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
# Converts raw results to TaskExecutionResult objects
|
121
|
+
# @param raw_results [Hash] Map of task ids to raw execution results
|
122
|
+
# @return [Hash<String, TaskExecutionResult>] The converted results
|
123
|
+
def convert_raw_results(raw_results)
|
124
|
+
raw_results.transform_values do |result|
|
125
|
+
result.is_a?(TaskExecutionResult) ? result : TaskExecutionResult.from_hash(result)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,464 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "set"
|
5
|
+
require "async"
|
6
|
+
require "async/barrier"
|
7
|
+
require "async/semaphore"
|
8
|
+
require_relative "task_failure"
|
9
|
+
require_relative "task_execution_result"
|
10
|
+
require_relative "plan_execution_result"
|
11
|
+
|
12
|
+
module Agentic
|
13
|
+
# Orchestrates the execution of tasks in a plan, handling dependencies and concurrency
|
14
|
+
# @attr_reader [String] plan_id Unique identifier for the plan
|
15
|
+
# @attr_reader [Hash] tasks Map of task ids to Task objects
|
16
|
+
# @attr_reader [Hash] execution_state Current state of all tasks in the plan
|
17
|
+
# @attr_reader [Hash] results Results of task execution
|
18
|
+
class PlanOrchestrator
|
19
|
+
attr_reader :plan_id, :tasks, :execution_state, :results, :retry_policy, :lifecycle_hooks
|
20
|
+
|
21
|
+
# Initializes a new plan orchestrator
|
22
|
+
# @param plan_id [String] Optional plan id, will be generated if not provided
|
23
|
+
# @param concurrency_limit [Integer] Maximum number of tasks to execute concurrently
|
24
|
+
# @param retry_policy [Hash] Configuration for retry behavior
|
25
|
+
# @param lifecycle_hooks [Hash] Configuration for execution lifecycle hooks
|
26
|
+
# @return [PlanOrchestrator] A new plan orchestrator instance
|
27
|
+
def initialize(plan_id: SecureRandom.uuid, concurrency_limit: 10, retry_policy: {}, lifecycle_hooks: {})
|
28
|
+
@plan_id = plan_id
|
29
|
+
@tasks = {}
|
30
|
+
@dependencies = {}
|
31
|
+
@results = {}
|
32
|
+
@execution_state = {
|
33
|
+
pending: Set.new,
|
34
|
+
in_progress: Set.new,
|
35
|
+
completed: Set.new,
|
36
|
+
failed: Set.new,
|
37
|
+
canceled: Set.new
|
38
|
+
}
|
39
|
+
@concurrency_limit = concurrency_limit
|
40
|
+
@async_tasks = {}
|
41
|
+
|
42
|
+
# Configure retry policy with defaults
|
43
|
+
@retry_policy = {
|
44
|
+
max_retries: 3,
|
45
|
+
retryable_errors: ["TimeoutError"],
|
46
|
+
backoff_strategy: :constant
|
47
|
+
}.merge(retry_policy)
|
48
|
+
|
49
|
+
# Configure lifecycle hooks with callable defaults (no-ops)
|
50
|
+
@lifecycle_hooks = {
|
51
|
+
before_agent_build: ->(task_id:, task:) {}, # Called before an agent is built
|
52
|
+
after_agent_build: ->(task_id:, task:, agent:, build_duration:) {}, # Called after an agent is built
|
53
|
+
before_task_execution: ->(task_id:, task:) {}, # Called before a task is executed
|
54
|
+
after_task_success: ->(task_id:, task:, result:, duration:) {}, # Called after a task succeeds
|
55
|
+
after_task_failure: ->(task_id:, task:, failure:, duration:) {}, # Called after a task fails
|
56
|
+
plan_completed: ->(plan_id:, status:, execution_time:, tasks:, results:) {} # Called when plan completes
|
57
|
+
}.merge(lifecycle_hooks)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Adds a task to the plan with optional dependencies
|
61
|
+
# @param task [Task] The task to add
|
62
|
+
# @param dependencies [Array<String>] Array of task ids that this task depends on
|
63
|
+
# @return [void]
|
64
|
+
def add_task(task, dependencies = [])
|
65
|
+
task_id = task.id
|
66
|
+
@tasks[task_id] = task
|
67
|
+
@dependencies[task_id] = Array(dependencies)
|
68
|
+
@execution_state[:pending].add(task_id)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Executes the plan, respecting task dependencies and concurrency limits
|
72
|
+
# @param agent_provider [Object] An object that provides agents for task execution
|
73
|
+
# @return [PlanExecutionResult] The structured execution results
|
74
|
+
def execute_plan(agent_provider)
|
75
|
+
@reactor = Async do |reactor|
|
76
|
+
@barrier = Async::Barrier.new
|
77
|
+
@semaphore = Async::Semaphore.new(@concurrency_limit, parent: @barrier)
|
78
|
+
|
79
|
+
# Track execution start time
|
80
|
+
@execution_start_time = Time.now
|
81
|
+
|
82
|
+
# Start with tasks that have no dependencies
|
83
|
+
eligible_tasks = find_eligible_tasks
|
84
|
+
|
85
|
+
# Initial execution of eligible tasks
|
86
|
+
eligible_tasks.each do |task_id|
|
87
|
+
schedule_task(task_id, agent_provider, @semaphore, @barrier)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Wait for all tasks to complete
|
91
|
+
@barrier.wait
|
92
|
+
|
93
|
+
# Track execution completion time
|
94
|
+
@execution_end_time = Time.now
|
95
|
+
|
96
|
+
# Call plan completion hook
|
97
|
+
@lifecycle_hooks[:plan_completed].call(
|
98
|
+
plan_id: @plan_id,
|
99
|
+
status: overall_status,
|
100
|
+
execution_time: @execution_end_time - @execution_start_time,
|
101
|
+
tasks: @tasks.transform_values(&:to_h),
|
102
|
+
results: @results
|
103
|
+
)
|
104
|
+
ensure
|
105
|
+
@barrier&.stop
|
106
|
+
# Ensure execution_end_time is set even if an exception occurred
|
107
|
+
@execution_end_time ||= Time.now
|
108
|
+
end
|
109
|
+
|
110
|
+
# Create and return a PlanExecutionResult
|
111
|
+
PlanExecutionResult.new(
|
112
|
+
plan_id: @plan_id,
|
113
|
+
status: overall_status,
|
114
|
+
execution_time: @execution_end_time - @execution_start_time,
|
115
|
+
tasks: @tasks.transform_values(&:to_h),
|
116
|
+
results: @results
|
117
|
+
)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Cancels execution of a specific task
|
121
|
+
# @param task_id [String] ID of the task to cancel
|
122
|
+
# @return [Boolean] True if the task was canceled, false otherwise
|
123
|
+
def cancel_task(task_id)
|
124
|
+
# Can only cancel pending or in_progress tasks
|
125
|
+
return false unless @execution_state[:pending].include?(task_id) ||
|
126
|
+
@execution_state[:in_progress].include?(task_id)
|
127
|
+
|
128
|
+
# If the task is pending, simply move it to canceled state
|
129
|
+
if @execution_state[:pending].include?(task_id)
|
130
|
+
transition_task_state(task_id, from: :pending, to: :canceled)
|
131
|
+
return true
|
132
|
+
end
|
133
|
+
|
134
|
+
# If the task is in progress, cancel its Async task
|
135
|
+
if @execution_state[:in_progress].include?(task_id) && @async_tasks[task_id]
|
136
|
+
@async_tasks[task_id].stop
|
137
|
+
transition_task_state(task_id, from: :in_progress, to: :canceled)
|
138
|
+
return true
|
139
|
+
end
|
140
|
+
|
141
|
+
false
|
142
|
+
end
|
143
|
+
|
144
|
+
# Cancels execution of the entire plan
|
145
|
+
# @return [void]
|
146
|
+
def cancel_plan
|
147
|
+
# Stop the reactor to cancel all async tasks
|
148
|
+
@reactor&.stop
|
149
|
+
|
150
|
+
# Move all pending and in_progress tasks to canceled state
|
151
|
+
@execution_state[:pending].each do |task_id|
|
152
|
+
transition_task_state(task_id, from: :pending, to: :canceled)
|
153
|
+
end
|
154
|
+
|
155
|
+
@execution_state[:in_progress].each do |task_id|
|
156
|
+
transition_task_state(task_id, from: :in_progress, to: :canceled)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Determines if a task failure is retryable based on retry policy
|
161
|
+
# @param task [Task] The failed task
|
162
|
+
# @param failure [TaskFailure] The failure details
|
163
|
+
# @return [Boolean] True if the task failure is retryable
|
164
|
+
def retry?(task:, failure:)
|
165
|
+
# Check if we've reached max retries
|
166
|
+
task.retry_count ||= 0
|
167
|
+
return false if task.retry_count >= @retry_policy[:max_retries]
|
168
|
+
|
169
|
+
# Check if error type is in retryable_errors list
|
170
|
+
@retry_policy[:retryable_errors].include?(failure.type)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Determines if a failure requires human intervention
|
174
|
+
# @param failure [TaskFailure] The failure details
|
175
|
+
# @return [Boolean] True if human intervention is required
|
176
|
+
def requires_intervention?(failure:)
|
177
|
+
# For now, we only identify a few error types that need human help
|
178
|
+
%w[AuthenticationError PermissionDeniedError ConfigurationError].include?(failure.type)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Applies a delay based on the backoff strategy before retrying
|
182
|
+
# @param task [Task] The task being retried
|
183
|
+
# @return [void]
|
184
|
+
def apply_retry_backoff(task:)
|
185
|
+
return if @retry_policy[:backoff_strategy] == :none
|
186
|
+
|
187
|
+
delay = case @retry_policy[:backoff_strategy]
|
188
|
+
when :constant
|
189
|
+
# Constant delay (default 1 second)
|
190
|
+
@retry_policy[:backoff_constant] || 1
|
191
|
+
when :linear
|
192
|
+
# Linear backoff (retry_count * base_delay)
|
193
|
+
base_delay = @retry_policy[:backoff_base] || 1
|
194
|
+
task.retry_count * base_delay
|
195
|
+
when :exponential
|
196
|
+
# Exponential backoff (base_delay * 2^retry_count)
|
197
|
+
base_delay = @retry_policy[:backoff_base] || 1
|
198
|
+
base_delay * (2**(task.retry_count - 1))
|
199
|
+
else
|
200
|
+
0
|
201
|
+
end
|
202
|
+
|
203
|
+
# Apply jitter if configured
|
204
|
+
if @retry_policy[:backoff_jitter]
|
205
|
+
jitter_factor = 0.25 # Default 25% jitter
|
206
|
+
jitter = rand(-delay * jitter_factor..delay * jitter_factor)
|
207
|
+
delay += jitter
|
208
|
+
end
|
209
|
+
|
210
|
+
# Sleep if there's a delay to apply
|
211
|
+
if delay > 0
|
212
|
+
Async do
|
213
|
+
Async::Task.current.sleep(delay) if delay > 0
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# Checks if all dependencies for a task are met
|
219
|
+
# @param task_id [String] ID of the task to check
|
220
|
+
# @return [Boolean] True if all dependencies are met, false otherwise
|
221
|
+
def all_dependencies_met?(task_id)
|
222
|
+
deps = @dependencies[task_id] || []
|
223
|
+
deps.all? do |dep_id|
|
224
|
+
@execution_state[:completed].include?(dep_id)
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# Finds tasks that are eligible for execution (have no dependencies)
|
229
|
+
# @return [Array<String>] IDs of eligible tasks
|
230
|
+
def find_eligible_tasks
|
231
|
+
@dependencies.select do |task_id, deps|
|
232
|
+
deps.empty? && @execution_state[:pending].include?(task_id)
|
233
|
+
end.keys
|
234
|
+
end
|
235
|
+
|
236
|
+
# Determines the overall status of the plan
|
237
|
+
# @return [Symbol] The overall status (:completed, :in_progress, or :partial_failure)
|
238
|
+
def overall_status
|
239
|
+
if @execution_state[:failed].any?
|
240
|
+
:partial_failure
|
241
|
+
elsif @execution_state[:pending].empty? && @execution_state[:in_progress].empty?
|
242
|
+
:completed
|
243
|
+
else
|
244
|
+
:in_progress
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
private
|
249
|
+
|
250
|
+
# Schedules a task for execution using the semaphore to limit concurrency
|
251
|
+
# @param task_id [String] ID of the task to schedule
|
252
|
+
# @param agent_provider [Object] Provides agents for task execution
|
253
|
+
# @param semaphore [Async::Semaphore] Controls concurrency
|
254
|
+
# @param barrier [Async::Barrier] Tracks task completion
|
255
|
+
# @return [void]
|
256
|
+
def schedule_task(task_id, agent_provider, semaphore, barrier)
|
257
|
+
return unless @execution_state[:pending].include?(task_id)
|
258
|
+
|
259
|
+
# Move to in_progress state
|
260
|
+
task = @tasks[task_id]
|
261
|
+
transition_task_state(task_id, from: :pending, to: :in_progress)
|
262
|
+
|
263
|
+
# Call before_task_execution hook
|
264
|
+
@lifecycle_hooks[:before_task_execution].call(
|
265
|
+
task_id: task_id,
|
266
|
+
task: task
|
267
|
+
)
|
268
|
+
|
269
|
+
# Schedule task execution with the semaphore
|
270
|
+
async_task = semaphore.async do
|
271
|
+
task_start_time = Time.now
|
272
|
+
begin
|
273
|
+
# Call before_agent_build hook
|
274
|
+
@lifecycle_hooks[:before_agent_build].call(
|
275
|
+
task_id: task_id,
|
276
|
+
task: task
|
277
|
+
)
|
278
|
+
|
279
|
+
agent_build_start = Time.now
|
280
|
+
agent = agent_provider.get_agent_for_task(task)
|
281
|
+
agent_build_duration = Time.now - agent_build_start
|
282
|
+
|
283
|
+
# Call after_agent_build hook
|
284
|
+
@lifecycle_hooks[:after_agent_build].call(
|
285
|
+
task_id: task_id,
|
286
|
+
task: task,
|
287
|
+
agent: agent,
|
288
|
+
build_duration: agent_build_duration
|
289
|
+
)
|
290
|
+
|
291
|
+
result = task.perform(agent)
|
292
|
+
task_duration = Time.now - task_start_time
|
293
|
+
|
294
|
+
# Record result and update state
|
295
|
+
if result.successful?
|
296
|
+
record_task_success(task_id, result.output)
|
297
|
+
|
298
|
+
# Call after_task_success hook
|
299
|
+
@lifecycle_hooks[:after_task_success].call(
|
300
|
+
task_id: task_id,
|
301
|
+
task: task,
|
302
|
+
result: result,
|
303
|
+
duration: task_duration
|
304
|
+
)
|
305
|
+
|
306
|
+
# Find and schedule dependent tasks
|
307
|
+
schedule_dependent_tasks(task_id, agent_provider, semaphore, barrier)
|
308
|
+
else
|
309
|
+
record_task_failure(task_id, result.failure)
|
310
|
+
|
311
|
+
# Call after_task_failure hook
|
312
|
+
@lifecycle_hooks[:after_task_failure].call(
|
313
|
+
task_id: task_id,
|
314
|
+
task: task,
|
315
|
+
failure: result.failure,
|
316
|
+
duration: task_duration
|
317
|
+
)
|
318
|
+
|
319
|
+
# Handle failure based on policy
|
320
|
+
handle_task_failure(task, result.failure, agent_provider, semaphore, barrier)
|
321
|
+
end
|
322
|
+
rescue => e
|
323
|
+
# Handle unexpected errors
|
324
|
+
failure = TaskFailure.from_exception(e, {
|
325
|
+
task_id: task_id,
|
326
|
+
context_type: "unexpected_error"
|
327
|
+
})
|
328
|
+
|
329
|
+
record_task_failure(task_id, failure)
|
330
|
+
|
331
|
+
# Call after_task_failure hook for unexpected errors
|
332
|
+
@lifecycle_hooks[:after_task_failure].call(
|
333
|
+
task_id: task_id,
|
334
|
+
task: task,
|
335
|
+
failure: failure,
|
336
|
+
duration: Time.now - task_start_time
|
337
|
+
)
|
338
|
+
|
339
|
+
Agentic.logger.error("Unexpected error in task #{task_id}: #{e.message}")
|
340
|
+
end
|
341
|
+
end
|
342
|
+
|
343
|
+
# Store the async task for potential cancellation
|
344
|
+
@async_tasks[task_id] = async_task
|
345
|
+
end
|
346
|
+
|
347
|
+
# Schedules tasks that depend on a completed task
|
348
|
+
# @param completed_task_id [String] ID of the completed task
|
349
|
+
# @param agent_provider [Object] Provides agents for task execution
|
350
|
+
# @param semaphore [Async::Semaphore] Controls concurrency
|
351
|
+
# @param barrier [Async::Barrier] Tracks task completion
|
352
|
+
# @return [void]
|
353
|
+
def schedule_dependent_tasks(completed_task_id, agent_provider, semaphore, barrier)
|
354
|
+
# Find tasks that depend on the completed task
|
355
|
+
dependent_tasks = @dependencies.select do |task_id, deps|
|
356
|
+
deps.include?(completed_task_id) && @execution_state[:pending].include?(task_id)
|
357
|
+
end.keys
|
358
|
+
|
359
|
+
# For each dependent task, check if all dependencies are satisfied
|
360
|
+
dependent_tasks.each do |task_id|
|
361
|
+
@dependencies[task_id]
|
362
|
+
all_deps_satisfied = all_dependencies_met?(task_id)
|
363
|
+
|
364
|
+
if all_deps_satisfied
|
365
|
+
schedule_task(task_id, agent_provider, semaphore, barrier)
|
366
|
+
end
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
# Handles a task failure according to policy
|
371
|
+
# @param task [Task] The failed task
|
372
|
+
# @param failure [TaskFailure] The failure details
|
373
|
+
# @param agent_provider [Object] Provides agents for task execution
|
374
|
+
# @param semaphore [Async::Semaphore] Controls concurrency
|
375
|
+
# @param barrier [Async::Barrier] Tracks task completion
|
376
|
+
# @return [void]
|
377
|
+
def handle_task_failure(task, failure, agent_provider, semaphore, barrier)
|
378
|
+
# Check if this error type is retryable according to policy
|
379
|
+
if retry?(task: task, failure: failure)
|
380
|
+
Agentic.logger.info("Task #{task.id} failed with #{failure.type}, retrying...")
|
381
|
+
retry_task(task, agent_provider, semaphore, barrier)
|
382
|
+
elsif requires_intervention?(failure: failure)
|
383
|
+
Agentic.logger.warn("Task #{task.id} failed with #{failure.type}, intervention required")
|
384
|
+
request_human_intervention(task, failure)
|
385
|
+
else
|
386
|
+
# Apply general failure policy
|
387
|
+
Agentic.logger.error("Task #{task.id} failed: #{failure.message}")
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
# Retries a failed task
|
392
|
+
# @param task [Task] The failed task
|
393
|
+
# @param agent_provider [Object] Provides agents for task execution
|
394
|
+
# @param semaphore [Async::Semaphore] Controls concurrency
|
395
|
+
# @param barrier [Async::Barrier] Tracks task completion
|
396
|
+
# @return [void]
|
397
|
+
def retry_task(task, agent_provider, semaphore, barrier)
|
398
|
+
# Check if the task can be retried
|
399
|
+
return unless task.status == :failed
|
400
|
+
|
401
|
+
# Initialize retry count if not already set
|
402
|
+
task.retry_count ||= 0
|
403
|
+
|
404
|
+
# Check if max retries reached
|
405
|
+
max_retries = @retry_policy[:max_retries]
|
406
|
+
if task.retry_count >= max_retries
|
407
|
+
Agentic.logger.warn("Max retries reached for task #{task.id}")
|
408
|
+
return
|
409
|
+
end
|
410
|
+
|
411
|
+
# Increment retry count
|
412
|
+
task.retry_count += 1
|
413
|
+
Agentic.logger.info("Retrying task #{task.id} (attempt #{task.retry_count} of #{max_retries})")
|
414
|
+
|
415
|
+
# Apply backoff delay if specified
|
416
|
+
apply_retry_backoff(task: task)
|
417
|
+
|
418
|
+
# Reset task state for retry
|
419
|
+
transition_task_state(task.id, from: :failed, to: :pending)
|
420
|
+
|
421
|
+
# Schedule retrying the task
|
422
|
+
schedule_task(task.id, agent_provider, semaphore, barrier)
|
423
|
+
end
|
424
|
+
|
425
|
+
# Requests human intervention for a failed task
|
426
|
+
# @param task [Task] The failed task
|
427
|
+
# @param failure [TaskFailure] The failure details
|
428
|
+
# @return [void]
|
429
|
+
def request_human_intervention(task, failure)
|
430
|
+
# This would integrate with the yet-to-be-implemented human intervention system
|
431
|
+
Agentic.logger.warn("Human intervention requested for task #{task.id}: #{failure.message}")
|
432
|
+
end
|
433
|
+
|
434
|
+
# Records a successful task completion with proper state transition and result storage
|
435
|
+
# @param task_id [String] ID of the completed task
|
436
|
+
# @param output [Hash] The task output
|
437
|
+
# @return [void]
|
438
|
+
def record_task_success(task_id, output)
|
439
|
+
transition_task_state(task_id, from: :in_progress, to: :completed)
|
440
|
+
@results[task_id] = TaskExecutionResult.success(output)
|
441
|
+
end
|
442
|
+
|
443
|
+
# Records a task failure with proper state transition and result storage
|
444
|
+
# @param task_id [String] ID of the failed task
|
445
|
+
# @param failure [TaskFailure] The failure details
|
446
|
+
# @return [void]
|
447
|
+
def record_task_failure(task_id, failure)
|
448
|
+
transition_task_state(task_id, from: :in_progress, to: :failed)
|
449
|
+
@results[task_id] = TaskExecutionResult.failure(failure)
|
450
|
+
end
|
451
|
+
|
452
|
+
# Transitions a task from one state to another
|
453
|
+
# @param task_id [String] ID of the task to transition
|
454
|
+
# @param from: [Symbol] Current state of the task
|
455
|
+
# @param to: [Symbol] Target state for the task
|
456
|
+
# @return [void]
|
457
|
+
def transition_task_state(task_id, from:, to:)
|
458
|
+
return unless @execution_state[from].include?(task_id)
|
459
|
+
|
460
|
+
@execution_state[from].delete(task_id)
|
461
|
+
@execution_state[to].add(task_id)
|
462
|
+
end
|
463
|
+
end
|
464
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Agentic
|
4
|
+
# Configuration object for the PlanOrchestrator
|
5
|
+
class PlanOrchestratorConfig
|
6
|
+
# @return [Integer] Maximum number of concurrent tasks
|
7
|
+
attr_accessor :concurrency_limit
|
8
|
+
|
9
|
+
# @return [Hash] Lifecycle hooks for the orchestrator
|
10
|
+
attr_accessor :lifecycle_hooks
|
11
|
+
|
12
|
+
# @return [Boolean] Whether to continue execution after a task failure
|
13
|
+
attr_accessor :continue_on_failure
|
14
|
+
|
15
|
+
# @return [RetryConfig] Retry configuration for tasks
|
16
|
+
attr_accessor :retry_config
|
17
|
+
|
18
|
+
# @return [Boolean] Whether to execute tasks asynchronously
|
19
|
+
attr_accessor :async
|
20
|
+
|
21
|
+
# Initializes a new plan orchestrator configuration
|
22
|
+
# @param concurrency_limit [Integer] Maximum number of concurrent tasks
|
23
|
+
# @param lifecycle_hooks [Hash] Lifecycle hooks for the orchestrator
|
24
|
+
# @param continue_on_failure [Boolean] Whether to continue execution after a task failure
|
25
|
+
# @param retry_config [RetryConfig, nil] Retry configuration for tasks
|
26
|
+
# @param async [Boolean] Whether to execute tasks asynchronously
|
27
|
+
def initialize(
|
28
|
+
concurrency_limit: 10,
|
29
|
+
lifecycle_hooks: {},
|
30
|
+
continue_on_failure: true,
|
31
|
+
retry_config: nil,
|
32
|
+
async: true
|
33
|
+
)
|
34
|
+
@concurrency_limit = concurrency_limit
|
35
|
+
@lifecycle_hooks = lifecycle_hooks
|
36
|
+
@continue_on_failure = continue_on_failure
|
37
|
+
@retry_config = retry_config || RetryConfig.new
|
38
|
+
@async = async
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns a hash of configuration options
|
42
|
+
# @return [Hash] The configuration options
|
43
|
+
def to_h
|
44
|
+
{
|
45
|
+
concurrency_limit: @concurrency_limit,
|
46
|
+
lifecycle_hooks: @lifecycle_hooks,
|
47
|
+
continue_on_failure: @continue_on_failure,
|
48
|
+
retry_config: {
|
49
|
+
max_retries: @retry_config.max_retries,
|
50
|
+
backoff_strategy: @retry_config.backoff_strategy,
|
51
|
+
backoff_options: @retry_config.backoff_options
|
52
|
+
},
|
53
|
+
async: @async
|
54
|
+
}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|