Skip to content

Commit ec0cc04

Browse files
codexMiley Chandonnet
authored andcommitted
AMPR-175 (#504): add milestone memory events
Adds MemoryEvent.MilestoneReached, per-agent milestone tracking, explicit milestone publishing APIs, CLI handling, tests, and docs. Implemented-by: Codex
1 parent 074bbc7 commit ec0cc04

20 files changed

Lines changed: 472 additions & 6 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ object EventCategorizer {
8585
is ProductEvent.FeatureRequested,
8686
is ProductEvent.EpicDefined,
8787
is ProductEvent.PhaseDefined,
88+
is MemoryEvent.MilestoneReached,
8889
is ProviderCallCompletedEvent,
8990
is TicketEvent.TicketCreated,
9091
is TicketEvent.TicketStatusChanged,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,9 @@ class WatchPresenter(
381381
val tags = event.tags.take(3).joinToString(", ")
382382
if (tags.isNotEmpty()) "Stored $type: $tags" else "Stored $type knowledge"
383383
}
384+
is MemoryEvent.MilestoneReached -> {
385+
"Milestone ${event.category.name.lowercase()}: ${event.description.take(50)}"
386+
}
384387
is Event.QuestionRaised -> "Question: ${event.questionText.take(50)}"
385388
is MeetingEvent.MeetingScheduled -> "Meeting: ${event.meeting.invitation.title.take(50)}"
386389
is MeetingEvent.MeetingStarted -> "Meeting started (${event.meetingId.takeLast(8)})"

ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/watch/presentation/EventCategorizerTest.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import link.socket.ampere.agents.domain.event.Event
88
import link.socket.ampere.agents.domain.event.EventSource
99
import link.socket.ampere.agents.domain.event.MemoryEvent
1010
import link.socket.ampere.agents.domain.event.MessageEvent
11+
import link.socket.ampere.agents.domain.event.MilestoneCategory
1112
import link.socket.ampere.agents.domain.event.SparkAppliedEvent
1213
import link.socket.ampere.agents.domain.event.TicketEvent
1314
import link.socket.ampere.agents.domain.memory.MemoryContext
@@ -173,6 +174,26 @@ class EventCategorizerTest {
173174
assertEquals(EventSignificance.ROUTINE, significance)
174175
}
175176

177+
@Test
178+
fun `SIGNIFICANT - MilestoneReached events are significant`() {
179+
val event = MemoryEvent.MilestoneReached(
180+
eventId = "evt-milestone-1",
181+
timestamp = Clock.System.now(),
182+
eventSource = EventSource.Agent("agent-test"),
183+
urgency = Urgency.MEDIUM,
184+
agentId = "agent-test",
185+
milestoneId = "milestone-1",
186+
description = "First successful code change",
187+
knowledgeId = null,
188+
taskId = "task-1",
189+
runId = "run-1",
190+
category = MilestoneCategory.FIRST_SUCCESS,
191+
)
192+
193+
val significance = EventCategorizer.categorize(event)
194+
assertEquals(EventSignificance.SIGNIFICANT, significance)
195+
}
196+
176197
@Test
177198
fun `ROUTINE events should not display by default`() {
178199
val routineEvent = MemoryEvent.KnowledgeRecalled(

ampere-cli/src/jvmTest/kotlin/link/socket/ampere/util/EventTypeParserTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlin.test.assertNull
66
import kotlin.test.assertTrue
77
import link.socket.ampere.agents.domain.event.Event
88
import link.socket.ampere.agents.domain.event.MeetingEvent
9+
import link.socket.ampere.agents.domain.event.MemoryEvent
910
import link.socket.ampere.agents.domain.event.MessageEvent
1011
import link.socket.ampere.agents.domain.event.NotificationEvent
1112
import link.socket.ampere.agents.domain.event.TicketEvent
@@ -80,6 +81,11 @@ class EventTypeParserTest {
8081
assertNotNull(EventTypeParser.parse("NotificationToHuman"))
8182
}
8283

84+
@Test
85+
fun `parse handles milestone event type`() {
86+
assertNotNull(EventTypeParser.parse("MilestoneReached"))
87+
}
88+
8389
@Test
8490
fun `parseMultiple returns set of EventClassTypes`() {
8591
val result = EventTypeParser.parseMultiple(
@@ -139,6 +145,9 @@ class EventTypeParserTest {
139145
// Should have notification events
140146
assertTrue(allNames.contains("notificationtoagent"))
141147
assertTrue(allNames.contains("notificationtohuman"))
148+
149+
// Should have milestone events
150+
assertTrue(allNames.contains("milestonereached"))
142151
}
143152

144153
@Test
@@ -159,6 +168,9 @@ class EventTypeParserTest {
159168
// Should have message events
160169
assertTrue(allTypes.contains(MessageEvent.ThreadCreated.EVENT_TYPE))
161170

171+
// Should have milestone events
172+
assertTrue(allTypes.contains(MemoryEvent.MilestoneReached.EVENT_TYPE))
173+
162174
// Should have notification events
163175
assertTrue(allTypes.contains(NotificationEvent.ToAgent.EVENT_TYPE))
164176
assertTrue(allTypes.contains(NotificationEvent.ToHuman.EVENT_TYPE))

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import kotlinx.serialization.Serializable
88
import link.socket.ampere.agents.domain.cognition.Spark
99
import link.socket.ampere.agents.domain.event.CognitiveStateSnapshot
1010
import link.socket.ampere.agents.domain.event.EventSource
11+
import link.socket.ampere.agents.domain.event.MilestoneCategory
1112
import link.socket.ampere.agents.domain.event.SparkAppliedEvent
1213
import link.socket.ampere.agents.domain.event.SparkRemovedEvent
1314
import link.socket.ampere.agents.domain.state.AgentState
15+
import link.socket.ampere.agents.domain.task.TaskId
1416
import link.socket.ampere.agents.events.api.AgentEventApi
1517
import link.socket.ampere.agents.events.utils.generateUUID
1618

@@ -112,4 +114,25 @@ abstract class ObservableAgent<S : AgentState>(
112114
}
113115
}
114116
}
117+
118+
/**
119+
* Explicitly publish a milestone for externally declared checkpoints.
120+
*/
121+
suspend fun reachMilestone(
122+
category: MilestoneCategory,
123+
description: String,
124+
knowledgeId: String? = null,
125+
taskId: TaskId? = null,
126+
runId: String? = null,
127+
milestoneId: String = generateUUID("milestone", id),
128+
) {
129+
eventApi?.reachMilestone(
130+
category = category,
131+
description = description,
132+
knowledgeId = knowledgeId,
133+
taskId = taskId,
134+
runId = runId,
135+
milestoneId = milestoneId,
136+
)
137+
}
115138
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/event/EventRegistry.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ object EventRegistry {
5959
// MemoryEvent types
6060
MemoryEvent.KnowledgeStored.EVENT_TYPE,
6161
MemoryEvent.KnowledgeRecalled.EVENT_TYPE,
62+
MemoryEvent.MilestoneReached.EVENT_TYPE,
6263

6364
// ToolEvent types
6465
ToolEvent.ToolRegistered.EVENT_TYPE,

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/event/MemoryEvent.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package link.socket.ampere.agents.domain.event
33
import kotlin.math.roundToInt
44
import kotlinx.datetime.Instant
55
import kotlinx.serialization.Serializable
6+
import link.socket.ampere.agents.definition.AgentId
67
import link.socket.ampere.agents.domain.Urgency
78
import link.socket.ampere.agents.domain.knowledge.KnowledgeType
89
import link.socket.ampere.agents.domain.memory.MemoryContext
10+
import link.socket.ampere.agents.domain.task.TaskId
911

1012
/** Format a double to 2 decimal places (multiplatform compatible). */
1113
private fun Double.formatPercent(): String {
@@ -174,4 +176,65 @@ sealed interface MemoryEvent : Event {
174176
const val EVENT_TYPE: EventType = "KnowledgeRecalled"
175177
}
176178
}
179+
180+
/**
181+
* Event emitted when an agent reaches a meaningful checkpoint.
182+
*
183+
* Milestones are low-volume signals for significant agent progress. They are
184+
* intentionally separate from [KnowledgeStored] so consumers can subscribe to
185+
* milestone semantics without filtering routine memory writes.
186+
*/
187+
@Serializable
188+
data class MilestoneReached(
189+
override val eventId: EventId,
190+
override val timestamp: Instant,
191+
override val eventSource: EventSource,
192+
val agentId: AgentId,
193+
val milestoneId: String,
194+
val description: String,
195+
val knowledgeId: String?,
196+
val taskId: TaskId?,
197+
val runId: String?,
198+
val category: MilestoneCategory,
199+
override val urgency: Urgency = Urgency.MEDIUM,
200+
) : MemoryEvent {
201+
202+
override val eventType: EventType = EVENT_TYPE
203+
204+
override fun getSummary(
205+
formatUrgency: (Urgency) -> String,
206+
formatSource: (EventSource) -> String,
207+
): String = buildString {
208+
append("Milestone reached: ")
209+
append(category.name.lowercase().replace('_', ' ').replaceFirstChar { it.uppercase() })
210+
append(" - ")
211+
append(description)
212+
taskId?.let { append(" (task=$it)") }
213+
knowledgeId?.let { append(" knowledge=$it") }
214+
append(" ${formatUrgency(urgency)}")
215+
append(" by ${formatSource(eventSource)}")
216+
}
217+
218+
companion object {
219+
const val EVENT_TYPE: EventType = "MilestoneReached"
220+
}
221+
}
222+
}
223+
224+
/**
225+
* Categories for [MemoryEvent.MilestoneReached].
226+
*
227+
* [FIRST_SUCCESS] and [RECOVERY] are emitted by the built-in milestone tracker.
228+
* [EXTERNAL] is emitted through the explicit milestone API for human-in-the-loop
229+
* approvals or external orchestration scripts. [KEY_INSIGHT] and [CHECKPOINT]
230+
* are available for future implementation when AMPERE promotes insight and
231+
* long-running progress signals into explicit milestone publish sites.
232+
*/
233+
@Serializable
234+
enum class MilestoneCategory {
235+
FIRST_SUCCESS,
236+
KEY_INSIGHT,
237+
RECOVERY,
238+
CHECKPOINT,
239+
EXTERNAL,
177240
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/event/TaskEvent.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ sealed interface TaskEvent : Event {
8585
override val eventSource: EventSource,
8686
override val timestamp: Instant,
8787
val summary: String,
88+
val taskType: String? = null,
89+
val runId: String? = null,
8890
override val urgency: Urgency = Urgency.MEDIUM,
8991
) : TaskEvent {
9092

@@ -108,6 +110,7 @@ sealed interface TaskEvent : Event {
108110
override val eventSource: EventSource,
109111
override val timestamp: Instant,
110112
val reason: String,
113+
val runId: String? = null,
111114
override val urgency: Urgency = Urgency.HIGH,
112115
) : TaskEvent {
113116

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/events/EventRepository.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,5 +252,8 @@ private fun Event.runIdOrNull(): String? = when (this) {
252252
is ToolEvent.ToolExecutionCompleted -> runId
253253
is MemoryEvent.KnowledgeStored -> runId
254254
is MemoryEvent.KnowledgeRecalled -> runId
255+
is MemoryEvent.MilestoneReached -> runId
256+
is link.socket.ampere.agents.domain.event.TaskEvent.TaskCompleted -> runId
257+
is link.socket.ampere.agents.domain.event.TaskEvent.TaskFailed -> runId
255258
else -> null
256259
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/events/api/AgentEventApi.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import link.socket.ampere.agents.domain.Urgency
88
import link.socket.ampere.agents.domain.event.Event
99
import link.socket.ampere.agents.domain.event.EventSource
1010
import link.socket.ampere.agents.domain.event.EventType
11+
import link.socket.ampere.agents.domain.event.MemoryEvent
1112
import link.socket.ampere.agents.domain.event.MessageEvent
13+
import link.socket.ampere.agents.domain.event.MilestoneCategory
1214
import link.socket.ampere.agents.domain.event.PermissionDeniedEvent
1315
import link.socket.ampere.agents.domain.event.PermissionDeniedReason
1416
import link.socket.ampere.agents.domain.event.TaskEvent
@@ -56,7 +58,13 @@ class AgentEventApi(
5658
private val eventRepository: EventRepository,
5759
private val eventSerialBus: EventSerialBus,
5860
private val logger: EventLogger = ConsoleEventLogger(),
61+
milestoneTrackerState: MilestoneTrackerState = MilestoneTrackerState(),
5962
) {
63+
private val milestoneTracker = MilestoneTracker(this, milestoneTrackerState)
64+
65+
init {
66+
milestoneTracker.start()
67+
}
6068

6169
/** Persist and publish a pre-constructed event. */
6270
suspend fun publish(event: Event) {
@@ -307,6 +315,8 @@ class AgentEventApi(
307315
suspend fun publishTaskCompleted(
308316
taskId: TaskId,
309317
summary: String,
318+
taskType: String? = null,
319+
runId: String? = null,
310320
urgency: Urgency = Urgency.MEDIUM,
311321
) {
312322
val event = TaskEvent.TaskCompleted(
@@ -315,6 +325,8 @@ class AgentEventApi(
315325
eventSource = EventSource.Agent(agentId),
316326
timestamp = Clock.System.now(),
317327
summary = summary,
328+
taskType = taskType,
329+
runId = runId,
318330
urgency = urgency,
319331
)
320332

@@ -325,6 +337,7 @@ class AgentEventApi(
325337
suspend fun publishTaskFailed(
326338
taskId: TaskId,
327339
reason: String,
340+
runId: String? = null,
328341
urgency: Urgency = Urgency.HIGH,
329342
) {
330343
val event = TaskEvent.TaskFailed(
@@ -333,6 +346,36 @@ class AgentEventApi(
333346
eventSource = EventSource.Agent(agentId),
334347
timestamp = Clock.System.now(),
335348
reason = reason,
349+
runId = runId,
350+
urgency = urgency,
351+
)
352+
353+
publish(event)
354+
}
355+
356+
/**
357+
* Publish a milestone when an agent or external consumer identifies a significant checkpoint.
358+
*/
359+
suspend fun reachMilestone(
360+
category: MilestoneCategory,
361+
description: String,
362+
knowledgeId: String? = null,
363+
taskId: TaskId? = null,
364+
runId: String? = null,
365+
milestoneId: String = generateUUID("milestone", agentId),
366+
urgency: Urgency = Urgency.MEDIUM,
367+
) {
368+
val event = MemoryEvent.MilestoneReached(
369+
eventId = generateUUID(milestoneId, agentId),
370+
timestamp = Clock.System.now(),
371+
eventSource = EventSource.Agent(agentId),
372+
agentId = agentId,
373+
milestoneId = milestoneId,
374+
description = description,
375+
knowledgeId = knowledgeId,
376+
taskId = taskId,
377+
runId = runId,
378+
category = category,
336379
urgency = urgency,
337380
)
338381

@@ -441,6 +484,20 @@ class AgentEventApi(
441484
}
442485
}
443486

487+
/** Subscribe to milestone events. */
488+
fun onMilestoneReached(
489+
filter: EventFilter<MemoryEvent.MilestoneReached> = EventFilter.noFilter(),
490+
handler: suspend (MemoryEvent.MilestoneReached, Subscription?) -> Unit,
491+
): Subscription =
492+
eventSerialBus.subscribe<MemoryEvent.MilestoneReached, EventSubscription.ByEventClassType>(
493+
agentId = agentId,
494+
eventType = MemoryEvent.MilestoneReached.EVENT_TYPE,
495+
) { event, subscription ->
496+
if (filter.execute(event)) {
497+
handler(event, subscription)
498+
}
499+
}
500+
444501
// ==================== Tool Event Publishing Methods ====================
445502

446503
/** Publish a ToolRegistered event with auto-generated ID and current timestamp. */

0 commit comments

Comments
 (0)