collavre 0.2.5 → 0.3.1
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/app/channels/collavre/comments_presence_channel.rb +6 -3
- data/app/controllers/collavre/admin/orchestration_controller.rb +177 -0
- data/app/controllers/collavre/comments_controller.rb +10 -1
- data/app/javascript/controllers/comments/presence_controller.js +5 -1
- data/app/jobs/collavre/ai_agent_job.rb +55 -4
- data/app/jobs/collavre/cron_action_job.rb +53 -0
- data/app/jobs/collavre/stuck_detector_job.rb +34 -0
- data/app/models/collavre/comment.rb +15 -15
- data/app/models/collavre/creative.rb +1 -1
- data/app/models/collavre/orchestrator_policy.rb +124 -0
- data/app/models/collavre/task.rb +3 -0
- data/app/models/collavre/user.rb +1 -1
- data/app/services/collavre/ai_agent_service.rb +32 -1
- data/app/services/collavre/ai_client.rb +4 -3
- data/app/services/collavre/comments/command_processor.rb +4 -1
- data/app/services/collavre/comments/topic_command.rb +101 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +142 -0
- data/app/services/collavre/orchestration/arbiter.rb +257 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
- data/app/services/collavre/orchestration/matcher.rb +98 -0
- data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
- data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
- data/app/services/collavre/orchestration/scheduler.rb +145 -0
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +303 -0
- data/app/services/collavre/system_events/context_builder.rb +34 -7
- data/app/services/collavre/system_events/dispatcher.rb +2 -7
- data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
- data/app/services/collavre/tools/cron_create_service.rb +73 -0
- data/app/services/collavre/tools/cron_list_service.rb +65 -0
- data/app/services/collavre/tools/cron_update_service.rb +82 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
- data/app/views/collavre/creatives/index.html.erb +1 -1
- data/config/locales/ai_agent.en.yml +21 -0
- data/config/locales/ai_agent.ko.yml +21 -0
- data/config/locales/comments.en.yml +8 -0
- data/config/locales/comments.ko.yml +8 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
- data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
- data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
- data/lib/collavre/version.rb +1 -1
- metadata +24 -13
- data/app/controllers/collavre/github_auth_controller.rb +0 -25
- data/app/models/collavre/github_account.rb +0 -10
- data/app/models/collavre/github_repository_link.rb +0 -19
- data/app/services/collavre/github/client.rb +0 -112
- data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
- data/app/services/collavre/github/pull_request_processor.rb +0 -181
- data/app/services/collavre/github/webhook_provisioner.rb +0 -130
- data/app/services/collavre/system_events/router.rb +0 -96
- data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
- data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
- data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
- data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +0 -5
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# PolicyResolver finds and merges applicable policies for a given context.
|
|
6
|
+
#
|
|
7
|
+
# Policy precedence (later overrides earlier):
|
|
8
|
+
# 1. Global policies
|
|
9
|
+
# 2. Creative-level policies
|
|
10
|
+
# 3. Topic-level policies
|
|
11
|
+
# 4. Agent-level policies (for scheduling)
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# resolver = PolicyResolver.new(context)
|
|
15
|
+
# resolver.arbitration_config
|
|
16
|
+
# # => { "strategy" => "primary_first", "max_responders" => 1 }
|
|
17
|
+
#
|
|
18
|
+
class PolicyResolver
|
|
19
|
+
# Default configurations when no policy is set
|
|
20
|
+
DEFAULTS = {
|
|
21
|
+
"arbitration" => {
|
|
22
|
+
"strategy" => "all",
|
|
23
|
+
"max_responders" => nil # nil means unlimited
|
|
24
|
+
},
|
|
25
|
+
"scheduling" => {
|
|
26
|
+
"max_concurrent_jobs" => 5,
|
|
27
|
+
"daily_token_limit" => 100_000,
|
|
28
|
+
"rate_limit_per_minute" => 20,
|
|
29
|
+
"backoff_strategy" => "exponential",
|
|
30
|
+
"topic_max_concurrent_jobs" => 1,
|
|
31
|
+
# Self-reflection settings
|
|
32
|
+
"self_reflection_enabled" => false,
|
|
33
|
+
"confidence_threshold" => 70,
|
|
34
|
+
"max_retries" => 3,
|
|
35
|
+
"retry_delay_seconds" => 5,
|
|
36
|
+
# Loop breaker settings
|
|
37
|
+
"loop_breaker_enabled" => false,
|
|
38
|
+
"ping_pong_threshold" => 5, # Max back-and-forth between same agents
|
|
39
|
+
"creative_retry_threshold" => 10, # Max tasks on same creative in time window
|
|
40
|
+
"creative_retry_window_minutes" => 30,
|
|
41
|
+
"task_timeout_minutes" => 60, # Max time for a single task
|
|
42
|
+
"token_spike_threshold" => 50_000, # Token usage spike in window
|
|
43
|
+
"token_spike_window_minutes" => 10
|
|
44
|
+
},
|
|
45
|
+
"stuck_detection" => {
|
|
46
|
+
"enabled" => true,
|
|
47
|
+
"task_stuck_threshold_minutes" => 30, # Task running for > N minutes
|
|
48
|
+
"creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
|
|
49
|
+
"create_system_comment" => true # Create system comment on escalation
|
|
50
|
+
}
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
def initialize(context)
|
|
54
|
+
@context = context
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get merged arbitration config
|
|
58
|
+
def arbitration_config
|
|
59
|
+
@arbitration_config ||= merge_policies("arbitration")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get merged scheduling config for a specific agent
|
|
63
|
+
def scheduling_config_for(agent)
|
|
64
|
+
base = merge_policies("scheduling")
|
|
65
|
+
|
|
66
|
+
# Layer agent-specific policies on top
|
|
67
|
+
agent_policies = OrchestratorPolicy.for_agent(agent.id, policy_type: "scheduling")
|
|
68
|
+
agent_policies.each do |policy|
|
|
69
|
+
base = base.merge(policy.config)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
base
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get merged scheduling config (without agent-specific overrides)
|
|
76
|
+
def scheduling_config
|
|
77
|
+
@scheduling_config ||= merge_policies("scheduling")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Topic-level concurrency limit
|
|
81
|
+
def topic_max_concurrent_jobs
|
|
82
|
+
scheduling_config["topic_max_concurrent_jobs"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Convenience methods
|
|
86
|
+
def arbitration_strategy
|
|
87
|
+
arbitration_config["strategy"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def max_responders
|
|
91
|
+
arbitration_config["max_responders"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def primary_agent_id
|
|
95
|
+
arbitration_config["primary_agent_id"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Bid strategy specific
|
|
99
|
+
def confidence_threshold
|
|
100
|
+
arbitration_config["confidence_threshold"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def bid_fallback_enabled?
|
|
104
|
+
arbitration_config["bid_fallback_enabled"] != false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Self-reflection settings
|
|
108
|
+
def self_reflection_enabled?
|
|
109
|
+
scheduling_config["self_reflection_enabled"] == true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self_reflection_config
|
|
113
|
+
{
|
|
114
|
+
"enabled" => scheduling_config["self_reflection_enabled"],
|
|
115
|
+
"confidence_threshold" => scheduling_config["confidence_threshold"],
|
|
116
|
+
"max_retries" => scheduling_config["max_retries"],
|
|
117
|
+
"retry_delay_seconds" => scheduling_config["retry_delay_seconds"]
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Loop breaker settings
|
|
122
|
+
def loop_breaker_enabled?
|
|
123
|
+
scheduling_config["loop_breaker_enabled"] == true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def loop_breaker_config
|
|
127
|
+
{
|
|
128
|
+
"enabled" => scheduling_config["loop_breaker_enabled"],
|
|
129
|
+
"ping_pong_threshold" => scheduling_config["ping_pong_threshold"],
|
|
130
|
+
"creative_retry_threshold" => scheduling_config["creative_retry_threshold"],
|
|
131
|
+
"creative_retry_window_minutes" => scheduling_config["creative_retry_window_minutes"],
|
|
132
|
+
"task_timeout_minutes" => scheduling_config["task_timeout_minutes"],
|
|
133
|
+
"token_spike_threshold" => scheduling_config["token_spike_threshold"],
|
|
134
|
+
"token_spike_window_minutes" => scheduling_config["token_spike_window_minutes"]
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Stuck detection settings
|
|
139
|
+
def stuck_detection_enabled?
|
|
140
|
+
stuck_detection_config["enabled"] == true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def stuck_detection_config
|
|
144
|
+
@stuck_detection_config ||= resolve("stuck_detection")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Generic resolve method for any policy type
|
|
148
|
+
def resolve(policy_type)
|
|
149
|
+
merge_policies(policy_type)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def merge_policies(policy_type)
|
|
155
|
+
# Start with defaults
|
|
156
|
+
config = DEFAULTS[policy_type]&.dup || {}
|
|
157
|
+
|
|
158
|
+
# Get applicable policies in priority order
|
|
159
|
+
policies = OrchestratorPolicy.for_context(@context, policy_type: policy_type)
|
|
160
|
+
|
|
161
|
+
# Merge each policy's config (later policies override earlier)
|
|
162
|
+
policies.each do |policy|
|
|
163
|
+
config = config.merge(policy.config)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
config
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# ResourceTracker tracks per-agent resource usage for scheduling decisions.
|
|
6
|
+
#
|
|
7
|
+
# Tracks:
|
|
8
|
+
# - Active jobs count (concurrency)
|
|
9
|
+
# - Daily token usage (quota)
|
|
10
|
+
# - Request rate (rate limiting)
|
|
11
|
+
#
|
|
12
|
+
# Uses Rails.cache by default (memory-based).
|
|
13
|
+
# Can be extended to use Redis for multi-server deployments.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# tracker = ResourceTracker.for(agent)
|
|
17
|
+
# tracker.active_jobs # => 2
|
|
18
|
+
# tracker.can_accept_work? # => true/false
|
|
19
|
+
# tracker.reserve!(job_id)
|
|
20
|
+
# tracker.release!(job_id, tokens_used: 1500)
|
|
21
|
+
#
|
|
22
|
+
class ResourceTracker
|
|
23
|
+
CACHE_EXPIRY_ACTIVE_JOBS = 1.hour
|
|
24
|
+
CACHE_EXPIRY_TOKENS = 2.days
|
|
25
|
+
CACHE_EXPIRY_RATE = 2.minutes
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def for(agent)
|
|
29
|
+
new(agent)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(agent)
|
|
34
|
+
@agent = agent
|
|
35
|
+
@key_prefix = "orchestrator:agent:#{agent.id}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Current number of active jobs for this agent
|
|
39
|
+
def active_jobs
|
|
40
|
+
active_job_set.size
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Total tokens used today
|
|
44
|
+
def tokens_today
|
|
45
|
+
cache_read(tokens_today_key) || 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Requests in the current minute window
|
|
49
|
+
def requests_this_minute
|
|
50
|
+
cache_read(rate_limit_key) || 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if rate limited (exceeds requests per minute)
|
|
54
|
+
def rate_limited?(limit)
|
|
55
|
+
requests_this_minute >= limit
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Reserve resources for a job (call before starting work)
|
|
59
|
+
def reserve!(job_id)
|
|
60
|
+
jobs = active_job_set
|
|
61
|
+
jobs.add(job_id.to_s)
|
|
62
|
+
cache_write(active_jobs_key, jobs, expires_in: CACHE_EXPIRY_ACTIVE_JOBS)
|
|
63
|
+
|
|
64
|
+
# Increment rate counter
|
|
65
|
+
increment_rate_counter
|
|
66
|
+
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Release resources after job completion
|
|
71
|
+
def release!(job_id, tokens_used: 0)
|
|
72
|
+
# Remove from active jobs
|
|
73
|
+
jobs = active_job_set
|
|
74
|
+
jobs.delete(job_id.to_s)
|
|
75
|
+
cache_write(active_jobs_key, jobs, expires_in: CACHE_EXPIRY_ACTIVE_JOBS)
|
|
76
|
+
|
|
77
|
+
# Add to daily token usage
|
|
78
|
+
if tokens_used.positive?
|
|
79
|
+
current_tokens = tokens_today
|
|
80
|
+
cache_write(tokens_today_key, current_tokens + tokens_used, expires_in: CACHE_EXPIRY_TOKENS)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get current resource status
|
|
87
|
+
def status
|
|
88
|
+
{
|
|
89
|
+
active_jobs: active_jobs,
|
|
90
|
+
tokens_today: tokens_today,
|
|
91
|
+
requests_this_minute: requests_this_minute
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Reset all tracking (useful for testing)
|
|
96
|
+
def reset!
|
|
97
|
+
Rails.cache.delete(active_jobs_key)
|
|
98
|
+
Rails.cache.delete(tokens_today_key)
|
|
99
|
+
Rails.cache.delete(rate_limit_key)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def active_job_set
|
|
105
|
+
cache_read(active_jobs_key) || Set.new
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def increment_rate_counter
|
|
109
|
+
current = requests_this_minute
|
|
110
|
+
cache_write(rate_limit_key, current + 1, expires_in: CACHE_EXPIRY_RATE)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def active_jobs_key
|
|
114
|
+
"#{@key_prefix}:active_jobs"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def tokens_today_key
|
|
118
|
+
"#{@key_prefix}:tokens:#{Date.current}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def rate_limit_key
|
|
122
|
+
# Window by minute
|
|
123
|
+
"#{@key_prefix}:rate:#{Time.current.to_i / 60}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cache_read(key)
|
|
127
|
+
Rails.cache.read(key)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cache_write(key, value, expires_in:)
|
|
131
|
+
Rails.cache.write(key, value, expires_in: expires_in)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# Scheduler decides when to execute agent jobs based on resource availability.
|
|
6
|
+
#
|
|
7
|
+
# Scheduling decisions:
|
|
8
|
+
# - :immediate - Execute now
|
|
9
|
+
# - :delayed - Execute after a delay (with :delay value)
|
|
10
|
+
# - :rejected - Do not execute (with :reason)
|
|
11
|
+
#
|
|
12
|
+
# Resource checks:
|
|
13
|
+
# - Active jobs count (concurrency limit)
|
|
14
|
+
# - Daily token usage (quota)
|
|
15
|
+
# - Rate limiting (requests per minute)
|
|
16
|
+
#
|
|
17
|
+
class Scheduler
|
|
18
|
+
# Default backoff delays
|
|
19
|
+
BACKOFF_DELAYS = {
|
|
20
|
+
immediate: 0,
|
|
21
|
+
linear: 30.seconds,
|
|
22
|
+
exponential_base: 10.seconds
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(context, policy_resolver: nil)
|
|
26
|
+
@context = context
|
|
27
|
+
@policy_resolver = policy_resolver || PolicyResolver.new(context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Schedule agents for execution
|
|
31
|
+
# @param agents [Array<User>] Agents selected by Arbiter
|
|
32
|
+
# @return [Array<Hash>] Scheduling decisions
|
|
33
|
+
# Each decision: { agent:, timing:, delay: (optional), reason: (optional) }
|
|
34
|
+
def schedule(agents)
|
|
35
|
+
topic_immediate_count = 0
|
|
36
|
+
agents.map do |agent|
|
|
37
|
+
decision = evaluate(agent, topic_immediate_count: topic_immediate_count)
|
|
38
|
+
topic_immediate_count += 1 if decision[:timing] == :immediate
|
|
39
|
+
decision
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def evaluate(agent, topic_immediate_count: 0)
|
|
46
|
+
tracker = ResourceTracker.for(agent)
|
|
47
|
+
config = @policy_resolver.scheduling_config_for(agent)
|
|
48
|
+
|
|
49
|
+
# Check 0: Loop breaker
|
|
50
|
+
if @policy_resolver.loop_breaker_enabled?
|
|
51
|
+
loop_result = LoopBreaker.new(@context, policy_resolver: @policy_resolver).check
|
|
52
|
+
if loop_result.should_break?
|
|
53
|
+
return loop_broken_decision(agent, loop_result)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check 1: Concurrency limit
|
|
58
|
+
max_concurrent = config["max_concurrent_jobs"] || 5
|
|
59
|
+
if tracker.active_jobs >= max_concurrent
|
|
60
|
+
return delayed_decision(agent, :busy, config)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check 2: Daily token quota
|
|
64
|
+
daily_limit = config["daily_token_limit"]
|
|
65
|
+
if daily_limit && tracker.tokens_today >= daily_limit
|
|
66
|
+
return rejected_decision(agent, :quota_exceeded)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check 3: Rate limit
|
|
70
|
+
rate_limit = config["rate_limit_per_minute"]
|
|
71
|
+
if rate_limit && tracker.rate_limited?(rate_limit)
|
|
72
|
+
return delayed_decision(agent, :rate_limited, config)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check 4: Topic concurrency limit (applies to Main topic with nil topic_id too)
|
|
76
|
+
topic_max = @policy_resolver.topic_max_concurrent_jobs
|
|
77
|
+
if topic_max && @context.key?("topic")
|
|
78
|
+
topic_id = @context.dig("topic", "id")
|
|
79
|
+
if (Task.running_for_topic(topic_id).count + topic_immediate_count) >= topic_max
|
|
80
|
+
return deferred_decision(agent)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# All checks passed - immediate execution
|
|
85
|
+
immediate_decision(agent)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def immediate_decision(agent)
|
|
89
|
+
{
|
|
90
|
+
agent: agent,
|
|
91
|
+
timing: :immediate
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def delayed_decision(agent, reason, config)
|
|
96
|
+
{
|
|
97
|
+
agent: agent,
|
|
98
|
+
timing: :delayed,
|
|
99
|
+
delay: calculate_delay(config, reason),
|
|
100
|
+
reason: reason
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def deferred_decision(agent)
|
|
105
|
+
{
|
|
106
|
+
agent: agent,
|
|
107
|
+
timing: :deferred
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def rejected_decision(agent, reason)
|
|
112
|
+
{
|
|
113
|
+
agent: agent,
|
|
114
|
+
timing: :rejected,
|
|
115
|
+
reason: reason
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def loop_broken_decision(agent, loop_result)
|
|
120
|
+
{
|
|
121
|
+
agent: agent,
|
|
122
|
+
timing: :rejected,
|
|
123
|
+
reason: :loop_detected,
|
|
124
|
+
loop_break_reason: loop_result.reason,
|
|
125
|
+
loop_break_details: loop_result.details
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def calculate_delay(config, _reason)
|
|
130
|
+
strategy = config["backoff_strategy"] || "linear"
|
|
131
|
+
|
|
132
|
+
case strategy
|
|
133
|
+
when "immediate"
|
|
134
|
+
BACKOFF_DELAYS[:immediate]
|
|
135
|
+
when "exponential"
|
|
136
|
+
# Simple exponential: base * 2^(active_jobs - max)
|
|
137
|
+
# For now, use a fixed multiplier
|
|
138
|
+
BACKOFF_DELAYS[:exponential_base] * 2
|
|
139
|
+
else # linear
|
|
140
|
+
BACKOFF_DELAYS[:linear]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# SelfReflectionEvaluator evaluates an agent's response and determines
|
|
6
|
+
# whether the agent should retry, escalate, or mark the task as done.
|
|
7
|
+
#
|
|
8
|
+
# It parses confidence signals from the response and compares against
|
|
9
|
+
# policy-configured thresholds.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# evaluator = SelfReflectionEvaluator.new(task)
|
|
13
|
+
# result = evaluator.evaluate
|
|
14
|
+
# case result.action
|
|
15
|
+
# when :done then task.update!(status: "done")
|
|
16
|
+
# when :retry then schedule_retry(result.delay)
|
|
17
|
+
# when :escalate then notify_admin(result.reason)
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
class SelfReflectionEvaluator
|
|
21
|
+
Result = Struct.new(:action, :delay, :reason, :confidence, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
# Patterns to extract confidence from agent response
|
|
24
|
+
CONFIDENCE_PATTERNS = [
|
|
25
|
+
/확신도[:\s]*(\d+)/i,
|
|
26
|
+
/confidence[:\s]*(\d+)/i,
|
|
27
|
+
/\[confidence[:\s]*(\d+)\]/i,
|
|
28
|
+
/확신[:\s]*(\d+)%?/i
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Patterns that indicate the agent is stuck or uncertain
|
|
32
|
+
UNCERTAINTY_PATTERNS = [
|
|
33
|
+
/잘\s*모르겠/,
|
|
34
|
+
/확실하지\s*않/,
|
|
35
|
+
/불확실/,
|
|
36
|
+
/도움이?\s*필요/,
|
|
37
|
+
/막혔/,
|
|
38
|
+
/I('m| am)\s*(not sure|uncertain|stuck)/i,
|
|
39
|
+
/need(s)?\s*help/i,
|
|
40
|
+
/cannot\s*(proceed|continue)/i
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
def initialize(task, response_content: nil, policy_resolver: nil)
|
|
44
|
+
@task = task
|
|
45
|
+
@response_content = response_content || ""
|
|
46
|
+
@policy_resolver = policy_resolver ||
|
|
47
|
+
PolicyResolver.new(task.trigger_event_payload || {})
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Evaluate the task's response and return a Result
|
|
51
|
+
# @return [Result] with :action (:done, :retry, :escalate), :delay, :reason, :confidence
|
|
52
|
+
def evaluate
|
|
53
|
+
return Result.new(action: :done, reason: "self_reflection_disabled") unless enabled?
|
|
54
|
+
|
|
55
|
+
confidence = parse_confidence
|
|
56
|
+
uncertainty_detected = detect_uncertainty
|
|
57
|
+
|
|
58
|
+
if confidence.nil? && !uncertainty_detected
|
|
59
|
+
# No confidence signal and no uncertainty - assume done
|
|
60
|
+
Result.new(action: :done, confidence: nil, reason: "no_signal")
|
|
61
|
+
elsif confidence && confidence >= threshold
|
|
62
|
+
# Confidence meets threshold - done
|
|
63
|
+
Result.new(action: :done, confidence: confidence, reason: "threshold_met")
|
|
64
|
+
elsif @task.retry_count >= max_retries
|
|
65
|
+
# Max retries exceeded - escalate
|
|
66
|
+
Result.new(
|
|
67
|
+
action: :escalate,
|
|
68
|
+
confidence: confidence,
|
|
69
|
+
reason: "max_retries_exceeded"
|
|
70
|
+
)
|
|
71
|
+
else
|
|
72
|
+
# Below threshold or uncertainty detected - retry
|
|
73
|
+
Result.new(
|
|
74
|
+
action: :retry,
|
|
75
|
+
delay: retry_delay,
|
|
76
|
+
confidence: confidence,
|
|
77
|
+
reason: uncertainty_detected ? "uncertainty_detected" : "below_threshold"
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Schedule a retry by creating a system message and re-triggering
|
|
83
|
+
# @param result [Result] pre-computed result from evaluate
|
|
84
|
+
def schedule_retry!(result)
|
|
85
|
+
return result unless result.action == :retry
|
|
86
|
+
|
|
87
|
+
# Increment retry count
|
|
88
|
+
@task.increment!(:retry_count)
|
|
89
|
+
|
|
90
|
+
# Create self-reflection prompt as a new comment
|
|
91
|
+
create_reflection_prompt
|
|
92
|
+
|
|
93
|
+
# Schedule the retry job
|
|
94
|
+
if result.delay.positive?
|
|
95
|
+
AiAgentJob.set(wait: result.delay.seconds).perform_later(@task)
|
|
96
|
+
else
|
|
97
|
+
AiAgentJob.perform_later(@task)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Escalate to admin users
|
|
104
|
+
# @param result [Result] pre-computed result from evaluate
|
|
105
|
+
def escalate!(result)
|
|
106
|
+
return result unless result.action == :escalate
|
|
107
|
+
|
|
108
|
+
@task.update!(status: "escalated")
|
|
109
|
+
create_escalation_notice
|
|
110
|
+
notify_admins
|
|
111
|
+
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def config
|
|
118
|
+
@config ||= @policy_resolver.self_reflection_config
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def enabled?
|
|
122
|
+
config["enabled"] == true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def threshold
|
|
126
|
+
config["confidence_threshold"] || 70
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def max_retries
|
|
130
|
+
config["max_retries"] || 3
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def retry_delay
|
|
134
|
+
config["retry_delay_seconds"] || 5
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def response_content
|
|
138
|
+
@response_content
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_confidence
|
|
142
|
+
CONFIDENCE_PATTERNS.each do |pattern|
|
|
143
|
+
match = response_content.match(pattern)
|
|
144
|
+
return match[1].to_i if match
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def detect_uncertainty
|
|
150
|
+
UNCERTAINTY_PATTERNS.any? { |pattern| response_content.match?(pattern) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def create_reflection_prompt
|
|
154
|
+
creative_id = @task.trigger_event_payload&.dig("creative", "id")
|
|
155
|
+
return unless creative_id && @task.topic_id
|
|
156
|
+
|
|
157
|
+
creative = Creative.find_by(id: creative_id)
|
|
158
|
+
return unless creative
|
|
159
|
+
|
|
160
|
+
# Create a system message prompting self-reflection
|
|
161
|
+
creative.comments.create!(
|
|
162
|
+
content: reflection_prompt_content,
|
|
163
|
+
user: system_user,
|
|
164
|
+
topic_id: @task.topic_id,
|
|
165
|
+
private: false
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def reflection_prompt_content
|
|
170
|
+
<<~PROMPT.strip
|
|
171
|
+
[시스템] 확신도가 낮습니다 (#{@task.retry_count + 1}/#{max_retries} 재시도).
|
|
172
|
+
|
|
173
|
+
다시 검토해주세요:
|
|
174
|
+
- 다른 접근법이 있나요?
|
|
175
|
+
- 부족한 정보가 있다면 @멘션으로 다른 Agent에게 요청하세요.
|
|
176
|
+
- 그래도 해결이 어려우면 상위로 에스컬레이션하세요.
|
|
177
|
+
|
|
178
|
+
응답 시 확신도를 명시해주세요: [confidence: 0-100]
|
|
179
|
+
PROMPT
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def create_escalation_notice
|
|
183
|
+
creative_id = @task.trigger_event_payload&.dig("creative", "id")
|
|
184
|
+
return unless creative_id && @task.topic_id
|
|
185
|
+
|
|
186
|
+
creative = Creative.find_by(id: creative_id)
|
|
187
|
+
return unless creative
|
|
188
|
+
|
|
189
|
+
creative.comments.create!(
|
|
190
|
+
content: escalation_notice_content,
|
|
191
|
+
user: system_user,
|
|
192
|
+
topic_id: @task.topic_id,
|
|
193
|
+
private: false
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def escalation_notice_content
|
|
198
|
+
<<~NOTICE.strip
|
|
199
|
+
[시스템] ⚠️ 에스컬레이션 발생
|
|
200
|
+
|
|
201
|
+
Agent: #{@task.agent&.name}
|
|
202
|
+
작업: #{@task.name}
|
|
203
|
+
재시도 횟수: #{@task.retry_count}회
|
|
204
|
+
|
|
205
|
+
최대 재시도 횟수를 초과하여 관리자에게 에스컬레이션되었습니다.
|
|
206
|
+
admin 권한을 가진 사용자의 검토가 필요합니다.
|
|
207
|
+
NOTICE
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def notify_admins
|
|
211
|
+
# Find admin users for this creative and notify them
|
|
212
|
+
creative_id = @task.trigger_event_payload&.dig("creative", "id")
|
|
213
|
+
return unless creative_id
|
|
214
|
+
|
|
215
|
+
creative = Creative.find_by(id: creative_id)
|
|
216
|
+
return unless creative
|
|
217
|
+
|
|
218
|
+
# This could trigger notifications via various channels
|
|
219
|
+
# For now, the escalation notice in the topic serves as notification
|
|
220
|
+
Rails.logger.info(
|
|
221
|
+
"[SelfReflection] Task #{@task.id} escalated for creative #{creative_id}"
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def system_user
|
|
226
|
+
@system_user ||= User.find_by(email: "system@collavre.local") ||
|
|
227
|
+
@task.agent # Fallback to agent if no system user
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|