Skip to content

Commit 775dded

Browse files
Skobeltsynclaude
andcommitted
feat(#1749): Loop.session(input) — bracket events per iteration
Loop runs the wrapped agent or pipeline repeatedly. The session emits the inner SkillStarted/SkillCompleted pair per iteration — all sharing the wrapped agent's agentId — terminated by a single Completed with the final OUT. Loop gains: - sessionExec — populated by Agent.loop with runAgentInSession and by Pipeline.loop with effectiveSessionExec - loopAgentId — wrapped agent's name (or pipeline's last agent's name). Used as agentId on the terminal Completed. - execution exposed as `internal` so the session extension can call it directly when sessionExec is null (no-factory fallback) Session iterates with the same control flow as invokeSuspend but routes each iteration through sessionExec to stream inner events. TDD red-first: 1 → 2 → 4 → 8 doubling-and-stop-at-8 loop produces exactly 3× SkillStarted + 3× SkillCompleted (all agentId=doubler), plus a terminal Completed(doubler, 8). Bracket pairs are contiguous in arrival order. Full regression sweep green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 21bcc12 commit 775dded

3 files changed

Lines changed: 167 additions & 6 deletions

File tree

src/main/kotlin/agents_engine/composition/loop/Loop.kt

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,21 @@ private const val DEFAULT_MAX_ITERATIONS = 1_000
1313
* widen without breaking callers.
1414
*/
1515
class Loop<IN, OUT>(
16-
private val execution: suspend (IN) -> OUT,
17-
private val next: (OUT) -> IN?,
18-
private val maxIterations: Int = DEFAULT_MAX_ITERATIONS,
16+
internal val execution: suspend (IN) -> OUT,
17+
internal val next: (OUT) -> IN?,
18+
internal val maxIterations: Int = DEFAULT_MAX_ITERATIONS,
19+
/**
20+
* #1749 — session-aware execution path. When non-null and called via
21+
* `loop.session(input)`, each iteration's wrapped agent (or pipeline)
22+
* streams events through the emitter with its own `agentId`.
23+
*/
24+
internal val sessionExec: (suspend (input: IN, emitter: agents_engine.model.AgentEventEmitter) -> OUT)? = null,
25+
/**
26+
* #1749 — `agentId` for the terminal `AgentEvent.Completed` emitted
27+
* by `loop.session(input)`. Null when constructed outside the factory
28+
* functions; the session falls back to `"loop"` in that case.
29+
*/
30+
internal val loopAgentId: String? = null,
1931
) {
2032
init {
2133
require(maxIterations > 0) { "Loop maxIterations must be greater than 0." }
@@ -42,11 +54,30 @@ fun <A, B> Agent<A, B>.loop(
4254
next: (B) -> A?,
4355
): Loop<A, B> {
4456
this.markPlaced("loop")
45-
return Loop(execution = { input -> this.invokeSuspend(input) }, next = next, maxIterations = maxIterations)
57+
val agent = this
58+
return Loop(
59+
execution = { input -> agent.invokeSuspend(input) },
60+
next = next,
61+
maxIterations = maxIterations,
62+
// #1749: stream the wrapped agent's events per iteration.
63+
sessionExec = { input, emitter ->
64+
agents_engine.runtime.events.runAgentInSession(agent, input, emitter).first
65+
},
66+
loopAgentId = agent.name,
67+
)
4668
}
4769

4870
fun <A, B> Pipeline<A, B>.loop(
4971
maxIterations: Int = DEFAULT_MAX_ITERATIONS,
5072
next: (B) -> A?,
51-
): Loop<A, B> =
52-
Loop(execution = { input -> this.invokeSuspend(input) }, next = next, maxIterations = maxIterations)
73+
): Loop<A, B> {
74+
val inner = this
75+
return Loop(
76+
execution = { input -> inner.invokeSuspend(input) },
77+
next = next,
78+
maxIterations = maxIterations,
79+
// #1749: pipeline's effectiveSessionExec streams every stage's events per iteration.
80+
sessionExec = { input, emitter -> inner.effectiveSessionExec(input, emitter) },
81+
loopAgentId = inner.agents.lastOrNull()?.name,
82+
)
83+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package agents_engine.composition.loop
2+
3+
import agents_engine.model.AgentEventEmitter
4+
import agents_engine.runtime.events.AgentEvent
5+
import agents_engine.runtime.events.AgentSession
6+
import kotlinx.coroutines.CompletableDeferred
7+
import kotlinx.coroutines.CoroutineScope
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.SupervisorJob
10+
import kotlinx.coroutines.channels.Channel
11+
import kotlinx.coroutines.flow.consumeAsFlow
12+
import kotlinx.coroutines.launch
13+
14+
/**
15+
* #1749 — start a streaming session against [this] loop.
16+
*
17+
* Each iteration runs the wrapped agent (or pipeline) under the same
18+
* emitter, so the consumer sees bracket events repeated per iteration
19+
* with the same `agentId`. The loop terminates when `next(out)` returns
20+
* null OR when `maxIterations` is reached (the latter throws, surfacing
21+
* as `AgentEvent.Failed`).
22+
*
23+
* Terminal `Completed` uses `loopAgentId` — the wrapped agent's name
24+
* (or the pipeline's last agent's name). Falls back to `"loop"` when
25+
* the Loop was constructed outside the factory functions.
26+
*/
27+
fun <IN, OUT> Loop<IN, OUT>.session(input: IN): AgentSession<OUT> {
28+
val loop = this
29+
val channel = Channel<AgentEvent<OUT>>(Channel.BUFFERED)
30+
val result = CompletableDeferred<OUT>()
31+
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
32+
val terminalAgentId = loop.loopAgentId ?: "loop"
33+
34+
scope.launch {
35+
@Suppress("UNCHECKED_CAST")
36+
val emitter: AgentEventEmitter = { event -> channel.trySend(event as AgentEvent<OUT>) }
37+
try {
38+
// sessionExec streams the wrapped run's inner events per
39+
// iteration; falls back to plain execution (no events) when
40+
// the Loop was constructed without the factory functions.
41+
val streamingExec: suspend (IN) -> OUT = loop.sessionExec?.let { f -> { input -> f(input, emitter) } }
42+
?: loop.execution
43+
44+
var current = streamingExec(input)
45+
var iterations = 1
46+
while (true) {
47+
val feedback = loop.next(current)
48+
if (feedback == null) break
49+
check(iterations < loop.maxIterations) {
50+
"Loop exceeded maxIterations=${loop.maxIterations} without termination."
51+
}
52+
current = streamingExec(feedback)
53+
iterations++
54+
}
55+
56+
channel.trySend(AgentEvent.Completed(terminalAgentId, current, null))
57+
channel.close()
58+
result.complete(current)
59+
} catch (t: Throwable) {
60+
channel.trySend(AgentEvent.Failed(terminalAgentId, t))
61+
channel.close()
62+
result.completeExceptionally(t)
63+
}
64+
}
65+
66+
return AgentSession(
67+
events = channel.consumeAsFlow(),
68+
resultDeferred = result,
69+
)
70+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package agents_engine.runtime.events
2+
3+
import agents_engine.composition.loop.loop
4+
import agents_engine.composition.loop.session
5+
import agents_engine.core.agent
6+
import kotlinx.coroutines.flow.toList
7+
import kotlinx.coroutines.test.runTest
8+
import kotlin.test.Test
9+
import kotlin.test.assertEquals
10+
import kotlin.test.assertIs
11+
12+
// #1749 — Loop.session runs the wrapped agent (or pipeline) repeatedly,
13+
// streaming bracket events per iteration with the same agentId.
14+
// Terminal Completed carries the loop's final output.
15+
16+
class LoopSessionTest {
17+
18+
@Test
19+
fun `loop session emits bracket events per iteration plus a terminal Completed`() = runTest {
20+
val doubler = agent<Int, Int>("doubler") {
21+
skills {
22+
skill<Int, Int>("double", "Doubles the input") {
23+
implementedBy { it * 2 }
24+
}
25+
}
26+
}
27+
// 1 → 2 → 4 → 8 (terminate, since 8 >= 8).
28+
val loop = doubler.loop(maxIterations = 5) { if (it >= 8) null else it }
29+
30+
val session = loop.session(1)
31+
val events = session.events.toList()
32+
val output = session.await()
33+
34+
assertEquals(8, output, "loop output is 1 * 2 * 2 * 2 = 8 after three iterations")
35+
36+
val starts = events.filterIsInstance<AgentEvent.SkillStarted>()
37+
val completedSkills = events.filterIsInstance<AgentEvent.SkillCompleted>()
38+
assertEquals(3, starts.size, "expected exactly 3 SkillStarted events (one per iteration); got: $starts")
39+
assertEquals(3, completedSkills.size, "expected exactly 3 SkillCompleted events; got: $completedSkills")
40+
41+
// All bracket events share the wrapped agent's agentId.
42+
starts.forEach { assertEquals("doubler", it.agentId, "every iteration must carry the wrapped agent's name") }
43+
completedSkills.forEach { assertEquals("doubler", it.agentId) }
44+
45+
// Order: each Started/Completed pair is contiguous, and iterations are sequential.
46+
// events[0] = Started, events[1] = Completed, events[2] = Started, events[3] = Completed, ...
47+
for (i in 0 until 3) {
48+
val started = events[i * 2]
49+
val completed = events[i * 2 + 1]
50+
assertIs<AgentEvent.SkillStarted>(started, "event ${i * 2} should be SkillStarted")
51+
assertIs<AgentEvent.SkillCompleted>(completed, "event ${i * 2 + 1} should be SkillCompleted")
52+
}
53+
54+
// Terminal Completed.
55+
val terminal = events.last()
56+
assertIs<AgentEvent.Completed<Int>>(terminal)
57+
assertEquals("doubler", terminal.agentId, "Loop's terminal Completed uses the wrapped agent's name")
58+
assertEquals(8, terminal.output)
59+
}
60+
}

0 commit comments

Comments
 (0)