Skip to content

Commit 00068bd

Browse files
wow-mileyMiley Chandonnet
andauthored
AMPR-171 #500: publish cognitive phase events (#514)
Implemented by Codex. Closes #500 Co-authored-by: Miley Chandonnet <miley@Mileys-Mac-mini.local>
1 parent faf9097 commit 00068bd

17 files changed

Lines changed: 406 additions & 21 deletions

File tree

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/EventCategorizer.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package link.socket.ampere.cli.watch.presentation
22

33
import link.socket.ampere.agents.domain.event.AgentSurfaceEvent
44
import link.socket.ampere.agents.domain.event.CognitiveEvent
5+
import link.socket.ampere.agents.domain.event.CognitivePhaseEvent
56
import link.socket.ampere.agents.domain.event.Event
67
import link.socket.ampere.agents.domain.event.FileSystemEvent
78
import link.socket.ampere.agents.domain.event.GitEvent
@@ -115,6 +116,7 @@ object EventCategorizer {
115116
is ToolEvent.ToolExecutionCompleted,
116117
is ProviderCallStartedEvent,
117118
is RoutingEvent.RouteSelected,
119+
is CognitivePhaseEvent,
118120
is SparkEvent -> EventSignificance.ROUTINE
119121

120122
is RoutingEvent.RouteFallback -> EventSignificance.SIGNIFICANT

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/renderer/EventRenderer.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import kotlinx.datetime.toLocalDateTime
1414
import link.socket.ampere.agents.domain.Urgency
1515
import link.socket.ampere.agents.domain.event.AgentSurfaceEvent
1616
import link.socket.ampere.agents.domain.event.CognitiveEvent
17+
import link.socket.ampere.agents.domain.event.CognitivePhaseEvent
1718
import link.socket.ampere.agents.domain.event.Event
1819
import link.socket.ampere.agents.domain.event.EventSource
1920
import link.socket.ampere.agents.domain.event.FileSystemEvent
@@ -73,6 +74,8 @@ class EventRenderer(
7374

7475
// Get the event type name
7576
val eventTypeName = when (event) {
77+
is CognitivePhaseEvent.PhaseEntered -> "PhaseEntered"
78+
is CognitivePhaseEvent.PhaseExited -> "PhaseExited"
7679
is SparkAppliedEvent -> "StackApplied"
7780
is SparkRemovedEvent -> "StackRemoved"
7881
is CognitiveStateSnapshot -> "StackSnapshot"
@@ -152,6 +155,8 @@ class EventRenderer(
152155
is ProductEvent -> "💡" to green
153156
is TicketEvent -> "🎫" to green
154157
is ToolEvent -> "🔧" to yellow
158+
is CognitivePhaseEvent.PhaseEntered -> "" to cyan
159+
is CognitivePhaseEvent.PhaseExited -> "" to gray
155160
// Routing events
156161
is RoutingEvent.RouteSelected -> "🔀" to cyan
157162
is RoutingEvent.RouteFallback -> "🔀" to yellow
@@ -174,6 +179,21 @@ class EventRenderer(
174179
*/
175180
private fun extractSummary(event: Event): String {
176181
return when (event) {
182+
is CognitivePhaseEvent.PhaseEntered -> buildString {
183+
append("Phase enter: ")
184+
event.oldPhase?.let { append("${it.name} -> ") }
185+
append(event.newPhase.name)
186+
append(" (depth: ${event.nestingDepth})")
187+
append(" ${formatUrgency(event.urgency)}")
188+
append(" from ${formatSource(event.eventSource)}")
189+
}
190+
is CognitivePhaseEvent.PhaseExited -> buildString {
191+
append("Phase exit: ${event.exitedPhase.name}")
192+
event.restoredToPhase?.let { append(" -> ${it.name}") }
193+
append(" (depth: ${event.nestingDepth})")
194+
append(" ${formatUrgency(event.urgency)}")
195+
append(" from ${formatSource(event.eventSource)}")
196+
}
177197
is SparkAppliedEvent -> buildString {
178198
append("Stack push: ${event.sparkName}")
179199
append(" (depth: ${event.stackDepth})")

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/definition/SparkBasedAgent.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ open class SparkBasedAgent<S : AgentState>(
103103
agent = this,
104104
phaseConfig = agentConfiguration.cognitiveConfig.phaseSparks,
105105
library = _phaseSparkLibrary,
106+
eventBus = _eventApi?.eventSerialBus,
106107
)
107108

108109
override val id: AgentId = agentId

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/cognition/sparks/PhaseSparkManager.kt

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ package link.socket.ampere.agents.domain.cognition.sparks
22

33
import kotlinx.coroutines.NonCancellable
44
import kotlinx.coroutines.withContext
5+
import kotlinx.datetime.Clock
56
import link.socket.ampere.agents.config.PhaseSparkConfig
67
import link.socket.ampere.agents.definition.AutonomousAgent
8+
import link.socket.ampere.agents.domain.event.CognitivePhaseEvent
9+
import link.socket.ampere.agents.domain.event.EventSource
710
import link.socket.ampere.agents.domain.state.AgentState
11+
import link.socket.ampere.agents.events.bus.EventSerialBus
12+
import link.socket.ampere.agents.events.utils.generateUUID
813
import link.socket.ampere.util.getEnvironmentVariable
914

1015
/**
@@ -24,20 +29,25 @@ class PhaseSparkManager<S : AgentState> private constructor(
2429
val enabled: Boolean,
2530
private val activePhases: Set<CognitivePhase>,
2631
private val library: PhaseSparkLibrary?,
32+
private val eventBus: EventSerialBus?,
2733
) {
2834
constructor(
2935
agent: AutonomousAgent<S>,
3036
enabled: Boolean = isPhaseSparkEnabled(),
3137
activePhases: Set<CognitivePhase> = DEFAULT_PHASES,
38+
eventBus: EventSerialBus? = null,
3239
) : this(
3340
agent = agent,
3441
enabled = enabled,
3542
activePhases = activePhases,
3643
library = null,
44+
eventBus = eventBus,
3745
)
3846

3947
private var appliedSparks: MutableList<PhaseSpark> = mutableListOf()
4048
private var currentPhase: CognitivePhase? = null
49+
private var currentPhaseNestingDepth: Int = 0
50+
private var withPhaseNestingDepth: Int = 0
4151

4252
fun enterPhase(phase: CognitivePhase) {
4353
enterPhaseInternal(phase, selectionContext = null)
@@ -47,9 +57,15 @@ class PhaseSparkManager<S : AgentState> private constructor(
4757
enterPhaseInternal(phase, selectionContext)
4858
}
4959

50-
private fun enterPhaseInternal(phase: CognitivePhase, selectionContext: SparkSelectionContext?) {
60+
private fun enterPhaseInternal(
61+
phase: CognitivePhase,
62+
selectionContext: SparkSelectionContext?,
63+
nestingDepth: Int = 0,
64+
oldPhaseOverride: CognitivePhase? = null,
65+
) {
5166
if (!enabled) return
5267

68+
val oldPhase = oldPhaseOverride ?: currentPhase
5369
if (appliedSparks.isNotEmpty() && currentPhase != phase) {
5470
removeAppliedSparks()
5571
}
@@ -67,11 +83,13 @@ class PhaseSparkManager<S : AgentState> private constructor(
6783
}
6884

6985
agent.currentCognitivePhase = phase
86+
publishPhaseEntered(oldPhase = oldPhase, newPhase = phase, nestingDepth = nestingDepth)
7087
for (spark in sparksToApply) {
7188
agent.spark<AutonomousAgent<S>>(spark)
7289
appliedSparks += spark
7390
}
7491
currentPhase = phase
92+
currentPhaseNestingDepth = nestingDepth
7593
}
7694

7795
suspend fun <R> withPhase(phase: CognitivePhase, block: suspend () -> R): R =
@@ -89,26 +107,41 @@ class PhaseSparkManager<S : AgentState> private constructor(
89107
block: suspend () -> R,
90108
): R {
91109
if (!enabled) return block()
110+
if (!isPhaseEnabled(phase)) return block()
92111

112+
val nestingDepth = withPhaseNestingDepth
93113
val previousPhase = currentPhase
94114
val previousSparks = appliedSparks.toList()
115+
val previousPhaseNestingDepth = currentPhaseNestingDepth
95116
appliedSparks = mutableListOf()
96117
currentPhase = null
97-
enterPhaseInternal(phase, selectionContext)
118+
enterPhaseInternal(
119+
phase = phase,
120+
selectionContext = selectionContext,
121+
nestingDepth = nestingDepth,
122+
oldPhaseOverride = previousPhase,
123+
)
124+
withPhaseNestingDepth = nestingDepth + 1
98125

99126
return try {
100127
block()
101128
} finally {
102129
withContext(NonCancellable) {
103-
removeAppliedSparks()
130+
withPhaseNestingDepth = nestingDepth
131+
removeAppliedSparks(
132+
restoredToPhase = previousPhase,
133+
nestingDepth = nestingDepth,
134+
)
104135

105136
if (previousPhase != null && previousSparks.isNotEmpty()) {
106-
agent.currentCognitivePhase = previousPhase
107-
for (spark in previousSparks) {
108-
agent.spark<AutonomousAgent<S>>(spark)
109-
appliedSparks += spark
110-
}
137+
appliedSparks = previousSparks.toMutableList()
111138
currentPhase = previousPhase
139+
currentPhaseNestingDepth = previousPhaseNestingDepth
140+
publishPhaseEntered(
141+
oldPhase = phase,
142+
newPhase = previousPhase,
143+
nestingDepth = previousPhaseNestingDepth,
144+
)
112145
}
113146
}
114147
}
@@ -126,14 +159,64 @@ class PhaseSparkManager<S : AgentState> private constructor(
126159

127160
private fun isPhaseEnabled(phase: CognitivePhase): Boolean = activePhases.contains(phase)
128161

129-
private fun removeAppliedSparks() {
162+
private fun removeAppliedSparks(
163+
restoredToPhase: CognitivePhase? = null,
164+
nestingDepth: Int = currentPhaseNestingDepth,
165+
) {
130166
if (appliedSparks.isEmpty()) return
167+
val exitedPhase = currentPhase ?: return
131168
repeat(appliedSparks.size) {
132169
agent.unspark()
133170
}
134171
appliedSparks.clear()
135172
currentPhase = null
136-
agent.currentCognitivePhase = null
173+
currentPhaseNestingDepth = 0
174+
agent.currentCognitivePhase = restoredToPhase
175+
publishPhaseExited(
176+
exitedPhase = exitedPhase,
177+
restoredToPhase = restoredToPhase,
178+
nestingDepth = nestingDepth,
179+
)
180+
}
181+
182+
private fun publishPhaseEntered(
183+
oldPhase: CognitivePhase?,
184+
newPhase: CognitivePhase,
185+
nestingDepth: Int,
186+
) {
187+
eventBus?.let { bus ->
188+
bus.publishAsync(
189+
CognitivePhaseEvent.PhaseEntered(
190+
eventId = generateUUID(agent.id, newPhase.name, nestingDepth.toString()),
191+
timestamp = Clock.System.now(),
192+
eventSource = EventSource.Agent(agent.id),
193+
agentId = agent.id,
194+
oldPhase = oldPhase,
195+
newPhase = newPhase,
196+
nestingDepth = nestingDepth,
197+
),
198+
)
199+
}
200+
}
201+
202+
private fun publishPhaseExited(
203+
exitedPhase: CognitivePhase,
204+
restoredToPhase: CognitivePhase?,
205+
nestingDepth: Int,
206+
) {
207+
eventBus?.let { bus ->
208+
bus.publishAsync(
209+
CognitivePhaseEvent.PhaseExited(
210+
eventId = generateUUID(agent.id, exitedPhase.name, nestingDepth.toString()),
211+
timestamp = Clock.System.now(),
212+
eventSource = EventSource.Agent(agent.id),
213+
agentId = agent.id,
214+
exitedPhase = exitedPhase,
215+
restoredToPhase = restoredToPhase,
216+
nestingDepth = nestingDepth,
217+
),
218+
)
219+
}
137220
}
138221

139222
companion object {
@@ -159,30 +242,35 @@ class PhaseSparkManager<S : AgentState> private constructor(
159242
fun <S : AgentState> create(
160243
agent: AutonomousAgent<S>,
161244
phaseConfig: PhaseSparkConfig? = null,
162-
): PhaseSparkManager<S> = createInternal(agent, phaseConfig, library = null)
245+
eventBus: EventSerialBus? = null,
246+
): PhaseSparkManager<S> = createInternal(agent, phaseConfig, library = null, eventBus = eventBus)
163247

164248
internal fun <S : AgentState> createWithLibrary(
165249
agent: AutonomousAgent<S>,
166250
phaseConfig: PhaseSparkConfig? = null,
167251
library: PhaseSparkLibrary? = null,
168-
): PhaseSparkManager<S> = createInternal(agent, phaseConfig, library)
252+
eventBus: EventSerialBus? = null,
253+
): PhaseSparkManager<S> = createInternal(agent, phaseConfig, library, eventBus)
169254

170255
internal fun <S : AgentState> internalCreate(
171256
agent: AutonomousAgent<S>,
172257
enabled: Boolean,
173258
activePhases: Set<CognitivePhase> = DEFAULT_PHASES,
174259
library: PhaseSparkLibrary? = null,
260+
eventBus: EventSerialBus? = null,
175261
): PhaseSparkManager<S> = PhaseSparkManager(
176262
agent = agent,
177263
enabled = enabled,
178264
activePhases = activePhases,
179265
library = library,
266+
eventBus = eventBus,
180267
)
181268

182269
private fun <S : AgentState> createInternal(
183270
agent: AutonomousAgent<S>,
184271
phaseConfig: PhaseSparkConfig?,
185272
library: PhaseSparkLibrary?,
273+
eventBus: EventSerialBus?,
186274
): PhaseSparkManager<S> {
187275
val enabledFromConfig = phaseConfig?.enabled ?: false
188276
val enabled = enabledFromConfig || isPhaseSparkEnabled()
@@ -192,6 +280,7 @@ class PhaseSparkManager<S : AgentState> private constructor(
192280
enabled = enabled,
193281
activePhases = phases,
194282
library = library,
283+
eventBus = eventBus,
195284
)
196285
}
197286
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package link.socket.ampere.agents.domain.event
2+
3+
import kotlinx.datetime.Instant
4+
import kotlinx.serialization.SerialName
5+
import kotlinx.serialization.Serializable
6+
import link.socket.ampere.agents.definition.AgentId
7+
import link.socket.ampere.agents.domain.Urgency
8+
import link.socket.ampere.agents.domain.cognition.sparks.CognitivePhase
9+
10+
/**
11+
* Events emitted when an agent enters or exits a cognitive phase.
12+
*
13+
* These events make phase transitions directly observable on the EventSerialBus
14+
* without requiring consumers to poll agent state or infer phases from Spark
15+
* application events.
16+
*/
17+
@Serializable
18+
sealed interface CognitivePhaseEvent : Event {
19+
val agentId: AgentId
20+
val nestingDepth: Int
21+
22+
@Serializable
23+
@SerialName("PhaseEntered")
24+
data class PhaseEntered(
25+
override val eventId: EventId,
26+
override val timestamp: Instant,
27+
override val eventSource: EventSource,
28+
override val agentId: AgentId,
29+
val oldPhase: CognitivePhase?,
30+
val newPhase: CognitivePhase,
31+
override val nestingDepth: Int,
32+
override val urgency: Urgency = Urgency.LOW,
33+
) : CognitivePhaseEvent {
34+
35+
override val eventType: EventType = EVENT_TYPE
36+
37+
override fun getSummary(
38+
formatUrgency: (Urgency) -> String,
39+
formatSource: (EventSource) -> String,
40+
): String = buildString {
41+
append("Phase entered: ")
42+
oldPhase?.let { append("${it.name} -> ") }
43+
append(newPhase.name)
44+
append(" (depth: $nestingDepth)")
45+
append(" ${formatUrgency(urgency)}")
46+
append(" from ${formatSource(eventSource)}")
47+
}
48+
49+
companion object {
50+
const val EVENT_TYPE: EventType = "PhaseEntered"
51+
}
52+
}
53+
54+
@Serializable
55+
@SerialName("PhaseExited")
56+
data class PhaseExited(
57+
override val eventId: EventId,
58+
override val timestamp: Instant,
59+
override val eventSource: EventSource,
60+
override val agentId: AgentId,
61+
val exitedPhase: CognitivePhase,
62+
val restoredToPhase: CognitivePhase?,
63+
override val nestingDepth: Int,
64+
override val urgency: Urgency = Urgency.LOW,
65+
) : CognitivePhaseEvent {
66+
67+
override val eventType: EventType = EVENT_TYPE
68+
69+
override fun getSummary(
70+
formatUrgency: (Urgency) -> String,
71+
formatSource: (EventSource) -> String,
72+
): String = buildString {
73+
append("Phase exited: ${exitedPhase.name}")
74+
restoredToPhase?.let { append(" -> ${it.name}") }
75+
append(" (depth: $nestingDepth)")
76+
append(" ${formatUrgency(urgency)}")
77+
append(" from ${formatSource(eventSource)}")
78+
}
79+
80+
companion object {
81+
const val EVENT_TYPE: EventType = "PhaseExited"
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)