Skip to content

Commit 48de537

Browse files
wow-mileyclaude
andauthored
AMPR-151 #460: add AgentPause primitive type system (#470)
I added the AgentPause sealed type system in commonMain — AgentPause data class with PauseUrgency (Routine/Important/Critical) and PauseCorrelationId, the EscalationChannel sealed hierarchy (Push, Voice, InAppCard, PublicLink), and AgentPauseResponse (Approved/Rejected/TimedOut). I stubbed the expect class ChannelAvailability with empty actuals on iOS, Android, JVM, JS, and Wasm so W1.5 and W2.2 can build against the contract before the real platform logic lands. I wrote round-trip serialization, ordering, and exhaustiveness tests, and documented the channel-fallback ordering, the urgency-to-default-channel mapping, and the relationship to ConsentRepository in docs/ampere/agent-pause.md. Closes #460 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bdb92f9 commit 48de537

12 files changed

Lines changed: 626 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package link.socket.ampere.pause
2+
3+
import kotlin.reflect.KClass
4+
5+
/**
6+
* Android stub. Real implementation in W1.5 will query NotificationManager
7+
* channel state, RecognizerIntent voice support, and the Ampere shell's
8+
* lifecycle state.
9+
*/
10+
actual class ChannelAvailability actual constructor() {
11+
12+
actual fun available(): List<KClass<out EscalationChannel>> = emptyList()
13+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package link.socket.ampere.pause
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* Correlation identifier used to pair a [AgentPause] request with its
7+
* matching [AgentPauseResponse]. Plugins generate one per pause they emit.
8+
*/
9+
typealias PauseCorrelationId = String
10+
11+
/**
12+
* A typed, serializable description of an agent that has paused execution and
13+
* is awaiting human input.
14+
*
15+
* `AgentPause` is the OS-native escalation primitive. Where [link.socket.ampere
16+
* .agents.events.surface.AgentSurface] models a Plugin asking the platform to
17+
* render UI, `AgentPause` models the higher-level *intent* of "I am stuck and
18+
* need a person." Channel selection (push notification → voice prompt →
19+
* in-app card → public link) is handled by the W1.5 channel-selector against
20+
* [suggestedChannels]; this type fixes the contract so renderers and per-Arc
21+
* override UI can build against it now.
22+
*
23+
* The contract intentionally lives in commonMain and carries no platform
24+
* references so it can be expressed across every Ampere target.
25+
*/
26+
@Serializable
27+
data class AgentPause(
28+
/** Stable identifier used to pair this pause with its response. */
29+
val correlationId: PauseCorrelationId,
30+
/** Human-readable explanation of why the agent paused. */
31+
val reason: String,
32+
/** How time-sensitive the pause is; drives default channel selection. */
33+
val urgency: PauseUrgency,
34+
/**
35+
* Ordered list of channels to attempt, highest priority first. The
36+
* channel selector walks this list and falls through to the next channel
37+
* on unavailability or non-response.
38+
*/
39+
val suggestedChannels: List<EscalationChannel>,
40+
/** How long to wait before considering the pause [AgentPauseResponse.TimedOut]. */
41+
val timeoutMillis: Long,
42+
/**
43+
* Public URL the user can visit to respond, used as the lowest-priority
44+
* fallback when no native channel is available. Optional — pauses that
45+
* must not leak to a public surface should leave this null.
46+
*/
47+
val fallbackUrl: String? = null,
48+
)
49+
50+
/**
51+
* Urgency level of an [AgentPause].
52+
*
53+
* This is intentionally distinct from [link.socket.ampere.agents.domain.Urgency]
54+
* (which classifies bus events). Pause urgency drives channel-selection
55+
* defaults: [Routine] prefers in-app card, [Important] prefers push
56+
* notification, [Critical] prefers voice prompt.
57+
*/
58+
@Serializable
59+
enum class PauseUrgency {
60+
/** Non-blocking; in-app card is the default channel. */
61+
Routine,
62+
63+
/** Time-sensitive; push notification is the default channel. */
64+
Important,
65+
66+
/** Blocking; voice prompt is the default channel. */
67+
Critical,
68+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package link.socket.ampere.pause
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* The reply to an [AgentPause].
7+
*
8+
* Every variant carries the same [correlationId] as the originating
9+
* [AgentPause], which is what lets the awaiter pair the response with the
10+
* request that emitted it.
11+
*/
12+
@Serializable
13+
sealed interface AgentPauseResponse {
14+
15+
/** Echo of the originating [AgentPause.correlationId]. */
16+
val correlationId: PauseCorrelationId
17+
18+
/**
19+
* The user approved the paused operation. [payload] carries any
20+
* additional structured data the responder supplied (e.g., a confirmation
21+
* note, a chosen option). It is opaque to the pause primitive — Plugin
22+
* code parses it.
23+
*/
24+
@Serializable
25+
data class Approved(
26+
override val correlationId: PauseCorrelationId,
27+
val payload: String? = null,
28+
) : AgentPauseResponse
29+
30+
/** The user rejected the paused operation. */
31+
@Serializable
32+
data class Rejected(
33+
override val correlationId: PauseCorrelationId,
34+
val reason: String? = null,
35+
) : AgentPauseResponse
36+
37+
/**
38+
* No channel produced a response within [AgentPause.timeoutMillis]. The
39+
* agent should treat the pause as unanswered.
40+
*/
41+
@Serializable
42+
data class TimedOut(
43+
override val correlationId: PauseCorrelationId,
44+
) : AgentPauseResponse
45+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package link.socket.ampere.pause
2+
3+
import kotlin.reflect.KClass
4+
5+
/**
6+
* Platform query for which [EscalationChannel] variants are currently
7+
* available on this device.
8+
*
9+
* The W1.5 channel-selector consults [available] before walking
10+
* [AgentPause.suggestedChannels] and skips channels whose variant is not in
11+
* the returned set. Per-platform implementations inspect runtime state
12+
* (notification permissions, voice-input availability, foreground/background)
13+
* to populate the result.
14+
*
15+
* This file ships intentionally empty stubs in W0.3 — the actual platform
16+
* logic lands in W1.5. The contract exists now so W1.5 and W2.2 can build
17+
* against a stable API.
18+
*/
19+
expect class ChannelAvailability() {
20+
21+
/**
22+
* Returns the [EscalationChannel] subclasses that are currently usable.
23+
* An empty result means no native channel is available — callers should
24+
* fall through to [EscalationChannel.PublicLink] if the originating
25+
* [AgentPause.fallbackUrl] is set, or treat the pause as unrouteable.
26+
*/
27+
fun available(): List<KClass<out EscalationChannel>>
28+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package link.socket.ampere.pause
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* A channel through which an [AgentPause] can solicit a human response.
7+
*
8+
* Each variant declares the minimal renderer parameters needed for a platform
9+
* implementation to dispatch the channel without reaching back into Ampere.
10+
* The channel-selector (W1.5) walks [AgentPause.suggestedChannels] in order;
11+
* the per-Arc override UI (W2.2) reorders or filters the list.
12+
*/
13+
@Serializable
14+
sealed interface EscalationChannel {
15+
16+
/**
17+
* Native push notification. The renderer maps [notificationCategory] onto
18+
* the platform-specific category/channel id (Android notification channel,
19+
* iOS `UNNotificationCategory`).
20+
*/
21+
@Serializable
22+
data class Push(
23+
val notificationCategory: String,
24+
val title: String,
25+
val body: String,
26+
val deeplink: String? = null,
27+
) : EscalationChannel
28+
29+
/**
30+
* Voice prompt — typically a synthesized read-out followed by a
31+
* speech-to-text response window. Used for [PauseUrgency.Critical] pauses
32+
* by default.
33+
*/
34+
@Serializable
35+
data class Voice(
36+
val prompt: String,
37+
val expectedResponseSeconds: Int = 15,
38+
val voiceProfile: String? = null,
39+
) : EscalationChannel
40+
41+
/**
42+
* In-app card displayed inside the Ampere shell. The renderer resolves
43+
* [cardKind] to an in-app surface (e.g., a banner, a modal, an Arc-pinned
44+
* card).
45+
*/
46+
@Serializable
47+
data class InAppCard(
48+
val cardKind: CardKind,
49+
val title: String,
50+
val body: String,
51+
val primaryActionLabel: String = "Approve",
52+
val secondaryActionLabel: String = "Reject",
53+
) : EscalationChannel {
54+
55+
@Serializable
56+
enum class CardKind {
57+
Banner,
58+
Modal,
59+
ArcPinned,
60+
}
61+
}
62+
63+
/**
64+
* Lowest-priority fallback: a public URL the user opens in a browser.
65+
* Used when no native channel is available, or when the agent
66+
* intentionally wants to reach a non-Ampere recipient.
67+
*/
68+
@Serializable
69+
data class PublicLink(
70+
val url: String,
71+
val displayLabel: String = "Open in browser",
72+
) : EscalationChannel
73+
}

0 commit comments

Comments
 (0)