Skip to content

Commit 6fd6b19

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/refactor-environment-agent-01Tri4v7dnVx7Yz7wmYre7KN
2 parents 3abd46a + c58429b commit 6fd6b19

6 files changed

Lines changed: 350 additions & 0 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package link.socket.ampere.agents.events.messages.escalation
2+
3+
import link.socket.ampere.agents.events.tickets.Escalation
4+
import link.socket.ampere.agents.events.tickets.PMPerceptionState
5+
import link.socket.ampere.agents.events.tickets.Ticket
6+
import link.socket.ampere.agents.events.tickets.TicketPriority
7+
8+
/**
9+
* Default implementation of EscalationPolicy that classifies blockers
10+
* into Escalation types and evaluates project state for urgency.
11+
*
12+
* Classification is based on keyword matching in the blocker reason.
13+
* Urgency is elevated based on project-wide conditions like:
14+
* - Number of blocked tickets across the project
15+
* - Number of overdue tickets
16+
* - Priority of blocked items
17+
* - Agent workload and capacity constraints
18+
*/
19+
class DefaultEscalationPolicy(
20+
private val config: Config = Config(),
21+
) : EscalationPolicy {
22+
23+
/**
24+
* Configuration for escalation thresholds.
25+
*/
26+
data class Config(
27+
/** Number of blocked tickets that triggers elevated urgency. */
28+
val blockedTicketThresholdElevated: Int = 3,
29+
/** Number of blocked tickets that triggers critical urgency. */
30+
val blockedTicketThresholdCritical: Int = 5,
31+
/** Number of overdue tickets that triggers elevated urgency. */
32+
val overdueTicketThresholdElevated: Int = 2,
33+
/** Number of overdue tickets that triggers critical urgency. */
34+
val overdueTicketThresholdCritical: Int = 5,
35+
/** Agent active ticket count that indicates high workload. */
36+
val agentHighWorkloadThreshold: Int = 5,
37+
/** Number of agents with high workload that triggers escalation. */
38+
val overloadedAgentThreshold: Int = 2,
39+
)
40+
41+
override fun evaluate(context: EscalationContext): EscalationDecision {
42+
val reasons = mutableListOf<String>()
43+
var urgencyLevel = EscalationDecision.UrgencyLevel.NORMAL
44+
45+
// Classify the blocker into an Escalation type
46+
val escalationType = classifyBlocker(context.blockingReason)
47+
reasons.add("Classified as ${escalationType::class.simpleName}: ${escalationType.description}")
48+
49+
// Evaluate urgency based on ticket priority
50+
val priorityUrgency = evaluateTicketPriority(context.ticket)
51+
if (priorityUrgency.urgencyLevel > urgencyLevel) {
52+
urgencyLevel = priorityUrgency.urgencyLevel
53+
reasons.addAll(priorityUrgency.reasons)
54+
}
55+
56+
// Evaluate urgency based on project state if available
57+
context.projectState?.let { projectState ->
58+
val projectUrgency = evaluateProjectState(projectState, context.ticket)
59+
if (projectUrgency.urgencyLevel > urgencyLevel) {
60+
urgencyLevel = projectUrgency.urgencyLevel
61+
}
62+
reasons.addAll(projectUrgency.reasons)
63+
}
64+
65+
return EscalationDecision(
66+
escalationType = escalationType,
67+
urgencyLevel = urgencyLevel,
68+
reasons = reasons,
69+
)
70+
}
71+
72+
// ==================== Blocker Classification ====================
73+
74+
/**
75+
* Classify the blocker reason into an Escalation type using keyword matching.
76+
* This could be extended to use LLM classification for more accurate results.
77+
*/
78+
private fun classifyBlocker(reason: String): Escalation {
79+
val lowerReason = reason.lowercase()
80+
81+
// Check for specific escalation types based on keywords
82+
return when {
83+
// Authorization/Approval
84+
containsAny(lowerReason, "approval", "permission", "authorize", "sign-off", "signoff") ->
85+
Escalation.Decision.Authorization
86+
87+
// Customer/External
88+
containsAny(lowerReason, "customer", "client", "user feedback", "stakeholder input") ->
89+
Escalation.External.Customer
90+
91+
// Vendor/External dependency
92+
containsAny(lowerReason, "vendor", "third-party", "api availability", "external service") ->
93+
Escalation.External.Vendor
94+
95+
// Budget/Cost
96+
containsAny(lowerReason, "budget", "cost", "expense", "purchase") ->
97+
Escalation.Budget.CostApproval
98+
99+
// Resource allocation
100+
containsAny(lowerReason, "resource", "capacity", "team", "staffing") ->
101+
Escalation.Budget.ResourceAllocation
102+
103+
// Timeline
104+
containsAny(lowerReason, "timeline", "deadline", "schedule", "delivery date") ->
105+
Escalation.Budget.Timeline
106+
107+
// Scope expansion
108+
containsAny(lowerReason, "scope creep", "additional feature", "new requirement", "expanded") ->
109+
Escalation.Scope.Expansion
110+
111+
// Scope reduction
112+
containsAny(lowerReason, "cut feature", "reduce scope", "simplify", "remove functionality") ->
113+
Escalation.Scope.Reduction
114+
115+
// Priority conflict
116+
containsAny(lowerReason, "priority conflict", "competing", "urgent request") ->
117+
Escalation.Priorities.Conflict
118+
119+
// Reprioritization
120+
containsAny(lowerReason, "reprioritize", "change priority", "urgent") ->
121+
Escalation.Priorities.Reprioritization
122+
123+
// Cross-team dependency
124+
containsAny(lowerReason, "dependency", "blocked by", "waiting for team", "cross-team") ->
125+
Escalation.Priorities.Dependency
126+
127+
// Product decision
128+
containsAny(lowerReason, "product decision", "feature direction", "business logic", "ux decision") ->
129+
Escalation.Decision.Product
130+
131+
// Technical decision
132+
containsAny(lowerReason, "technical decision", "technology choice", "library", "implementation strategy") ->
133+
Escalation.Decision.Technical
134+
135+
// Requirements clarification
136+
containsAny(lowerReason, "requirement", "clarification", "unclear", "ambiguous", "specification") ->
137+
Escalation.Discussion.Requirements
138+
139+
// Architecture
140+
containsAny(lowerReason, "architecture", "system structure", "component", "pattern") ->
141+
Escalation.Discussion.Architecture
142+
143+
// Design
144+
containsAny(lowerReason, "design", "ui", "ux", "interface", "layout") ->
145+
Escalation.Discussion.Design
146+
147+
// Code review (default for technical blockers)
148+
containsAny(lowerReason, "review", "code", "pr", "pull request", "implementation") ->
149+
Escalation.Discussion.CodeReview
150+
151+
// Default to requirements clarification for unclear blockers
152+
else -> Escalation.Discussion.Requirements
153+
}
154+
}
155+
156+
private fun containsAny(text: String, vararg keywords: String): Boolean =
157+
keywords.any { text.contains(it) }
158+
159+
// ==================== Ticket Priority Evaluation ====================
160+
161+
private data class UrgencyEvaluation(
162+
val urgencyLevel: EscalationDecision.UrgencyLevel,
163+
val reasons: List<String>,
164+
)
165+
166+
private fun evaluateTicketPriority(ticket: Ticket): UrgencyEvaluation {
167+
return when (ticket.priority) {
168+
TicketPriority.CRITICAL -> UrgencyEvaluation(
169+
urgencyLevel = EscalationDecision.UrgencyLevel.CRITICAL,
170+
reasons = listOf("Critical priority ticket blocked"),
171+
)
172+
TicketPriority.HIGH -> UrgencyEvaluation(
173+
urgencyLevel = EscalationDecision.UrgencyLevel.ELEVATED,
174+
reasons = listOf("High priority ticket blocked"),
175+
)
176+
else -> UrgencyEvaluation(
177+
urgencyLevel = EscalationDecision.UrgencyLevel.NORMAL,
178+
reasons = emptyList(),
179+
)
180+
}
181+
}
182+
183+
// ==================== Project State Evaluation ====================
184+
185+
private fun evaluateProjectState(
186+
state: PMPerceptionState,
187+
currentTicket: Ticket,
188+
): UrgencyEvaluation {
189+
val reasons = mutableListOf<String>()
190+
var urgencyLevel = EscalationDecision.UrgencyLevel.NORMAL
191+
192+
// Check blocked ticket count (including this new blocker)
193+
val totalBlocked = state.blockedTickets.size + 1
194+
when {
195+
totalBlocked >= config.blockedTicketThresholdCritical -> {
196+
urgencyLevel = EscalationDecision.UrgencyLevel.CRITICAL
197+
reasons.add("Critical: $totalBlocked tickets now blocked (threshold: ${config.blockedTicketThresholdCritical})")
198+
}
199+
totalBlocked >= config.blockedTicketThresholdElevated -> {
200+
if (urgencyLevel < EscalationDecision.UrgencyLevel.ELEVATED) {
201+
urgencyLevel = EscalationDecision.UrgencyLevel.ELEVATED
202+
}
203+
reasons.add("Elevated: $totalBlocked tickets now blocked (threshold: ${config.blockedTicketThresholdElevated})")
204+
}
205+
}
206+
207+
// Check overdue ticket count
208+
val overdueCount = state.overdueTickets.size
209+
when {
210+
overdueCount >= config.overdueTicketThresholdCritical -> {
211+
if (urgencyLevel < EscalationDecision.UrgencyLevel.CRITICAL) {
212+
urgencyLevel = EscalationDecision.UrgencyLevel.CRITICAL
213+
}
214+
reasons.add("Critical: $overdueCount tickets overdue (threshold: ${config.overdueTicketThresholdCritical})")
215+
}
216+
overdueCount >= config.overdueTicketThresholdElevated -> {
217+
if (urgencyLevel < EscalationDecision.UrgencyLevel.ELEVATED) {
218+
urgencyLevel = EscalationDecision.UrgencyLevel.ELEVATED
219+
}
220+
reasons.add("Elevated: $overdueCount tickets overdue (threshold: ${config.overdueTicketThresholdElevated})")
221+
}
222+
}
223+
224+
// Check if there are multiple critical/high priority blocked tickets
225+
val highPriorityBlocked = state.blockedTickets.count {
226+
it.priority == TicketPriority.CRITICAL || it.priority == TicketPriority.HIGH
227+
}
228+
if (highPriorityBlocked >= 2) {
229+
if (urgencyLevel < EscalationDecision.UrgencyLevel.ELEVATED) {
230+
urgencyLevel = EscalationDecision.UrgencyLevel.ELEVATED
231+
}
232+
reasons.add("Multiple high-priority tickets blocked ($highPriorityBlocked)")
233+
}
234+
235+
// Check agent workload capacity
236+
val overloadedAgents = state.agentWorkloads.values.count {
237+
it.activeCount > config.agentHighWorkloadThreshold
238+
}
239+
if (overloadedAgents >= config.overloadedAgentThreshold) {
240+
if (urgencyLevel < EscalationDecision.UrgencyLevel.ELEVATED) {
241+
urgencyLevel = EscalationDecision.UrgencyLevel.ELEVATED
242+
}
243+
reasons.add("$overloadedAgents agents have high workload (>${config.agentHighWorkloadThreshold} active tickets)")
244+
}
245+
246+
// Check if the assigned agent is already overloaded
247+
currentTicket.assignedAgentId?.let { agentId ->
248+
state.agentWorkloads[agentId]?.let { workload ->
249+
if (workload.blockedCount >= 2) {
250+
if (urgencyLevel < EscalationDecision.UrgencyLevel.ELEVATED) {
251+
urgencyLevel = EscalationDecision.UrgencyLevel.ELEVATED
252+
}
253+
reasons.add("Assigned agent $agentId has ${workload.blockedCount} blocked tickets")
254+
}
255+
}
256+
}
257+
258+
return UrgencyEvaluation(
259+
urgencyLevel = urgencyLevel,
260+
reasons = reasons,
261+
)
262+
}
263+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package link.socket.ampere.agents.events.messages.escalation
2+
3+
import link.socket.ampere.agents.core.AgentId
4+
import link.socket.ampere.agents.events.tickets.PMPerceptionState
5+
import link.socket.ampere.agents.events.tickets.Ticket
6+
7+
/**
8+
* Context for evaluating escalation decisions.
9+
*
10+
* Contains information about the specific blocker and the overall project state.
11+
*/
12+
data class EscalationContext(
13+
/** The ticket that is being blocked. */
14+
val ticket: Ticket,
15+
/** The reason for the blocker. */
16+
val blockingReason: String,
17+
/** The agent reporting the blocker. */
18+
val reportedByAgentId: AgentId,
19+
/** The current project state. May be null if not available. */
20+
val projectState: PMPerceptionState?,
21+
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package link.socket.ampere.agents.events.messages.escalation
2+
3+
import link.socket.ampere.agents.events.tickets.Escalation
4+
5+
/**
6+
* Represents the decision made by an escalation policy.
7+
*
8+
* Contains the classified escalation type and urgency information
9+
* based on both the blocker reason and overall project state.
10+
*/
11+
data class EscalationDecision(
12+
/** The classified escalation type determining how to handle this blocker. */
13+
val escalationType: Escalation,
14+
/** The urgency level of the escalation based on project state. */
15+
val urgencyLevel: UrgencyLevel,
16+
/** Reasons explaining the escalation classification and urgency. */
17+
val reasons: List<String>,
18+
) {
19+
/**
20+
* Urgency levels for escalation decisions based on project state.
21+
*/
22+
enum class UrgencyLevel {
23+
/** Normal escalation - no immediate action required. */
24+
NORMAL,
25+
/** Elevated urgency - should be addressed soon. */
26+
ELEVATED,
27+
/** Critical urgency - requires immediate attention. */
28+
CRITICAL,
29+
}
30+
}
31+
32+
/**
33+
* Compare urgency levels for ordering.
34+
*/
35+
internal operator fun EscalationDecision.UrgencyLevel.compareTo(
36+
other: EscalationDecision.UrgencyLevel,
37+
): Int = this.ordinal.compareTo(other.ordinal)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package link.socket.ampere.agents.events.messages.escalation
2+
3+
/**
4+
* Policy interface for classifying blockers and determining escalation behavior.
5+
*
6+
* Implementations analyze blocker reasons and project state to:
7+
* 1. Classify the blocker into an appropriate Escalation type
8+
* 2. Determine urgency based on project-wide conditions
9+
*/
10+
interface EscalationPolicy {
11+
/**
12+
* Evaluate escalation for the given context.
13+
*
14+
* @param context The escalation context containing ticket, blocker, and project state.
15+
* @return The escalation decision with type classification and urgency.
16+
*/
17+
fun evaluate(context: EscalationContext): EscalationDecision
18+
}

shared/src/commonMain/kotlin/link/socket/ampere/agents/events/utils/EventLogger.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ interface EventLogger {
1919

2020
/** Log an error without crashing the app. */
2121
fun logError(message: String, throwable: Throwable? = null)
22+
23+
/** Log an informational message. */
24+
fun logInfo(message: String)
2225
}
2326

2427
/**
@@ -44,4 +47,8 @@ class ConsoleEventLogger : EventLogger {
4447
println("[EventBus][ERROR] $message" + (throwable?.let { ": ${it::class.simpleName} - ${it.message}" } ?: ""))
4548
throwable?.printStackTrace()
4649
}
50+
51+
override fun logInfo(message: String) {
52+
println("[EventBus][INFO] $message")
53+
}
4754
}

shared/src/jvmTest/kotlin/link/socket/ampere/agents/events/EventBusLoggingAndErrorsTest.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ class EventBusLoggingAndErrorsTest {
7676
override fun logError(message: String, throwable: Throwable?) {
7777
errors += message
7878
}
79+
80+
override fun logInfo(message: String) {
81+
// No-op for tests
82+
}
7983
}
8084

8185
@Test

0 commit comments

Comments
 (0)