Skip to content

Commit 20c3bed

Browse files
Skobeltsynclaude
andcommitted
feat(#1747): wrap session — events flow from teacher and student
teacher wrap student (returning Pipeline<IN, OUT>) now populates sessionExec so both agents stream events during pipeline.session(input). Consolidation: invokeSuspendForSession gains an optional promptOverride parameter. invokeSuspendWithPromptOverride becomes a thin shim that delegates with emitter=null — same byte-for-byte non-streaming behavior wrap had pre-streaming. runAgentInSession also takes promptOverride and passes it through. Wrap's sessionExec: - runAgentInSession(teacher, input, emitter) → teacher's events stream, output is the prompt override - runAgentInSession(student, input, emitter, promptOverride = override) → student's events stream under the override Student receives the SAME input as teacher (wrap semantics — teacher's String OUT becomes the system prompt for student, not the input). TDD red-first: WrapSessionTest builds an implementedBy teacher and an agentic student with a stub ModelClient. Asserts ordered events from both agents PLUS captures the student's system prompt to verify the override was actually active (the teacher's emitted text appears, the student's baked-in prompt does not). Full regression sweep green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa06e2a commit 20c3bed

4 files changed

Lines changed: 142 additions & 21 deletions

File tree

src/main/kotlin/agents_engine/composition/wrap/Wrap.kt

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,23 @@ infix fun <IN, OUT> Agent<IN, String>.wrap(student: Agent<IN, OUT>): Pipeline<IN
4545
this.markPlaced("pipeline")
4646
student.markPlaced("pipeline")
4747
val teacher = this
48-
return Pipeline(listOf(teacher, student)) { input ->
49-
val promptOverride = teacher.invokeSuspend(input)
50-
student.invokeSuspendWithPromptOverride(input, promptOverride)
51-
}
48+
return Pipeline(
49+
agents = listOf(teacher, student),
50+
// #1747: streaming path — teacher streams its events, then its
51+
// typed `String` output becomes the student's prompt override
52+
// for the wrapped run. Both agents' events flow with their own
53+
// agentIds.
54+
sessionExec = { input, emitter ->
55+
val (override, _) = agents_engine.runtime.events.runAgentInSession(teacher, input, emitter)
56+
val (out, _) = agents_engine.runtime.events.runAgentInSession(
57+
student, input, emitter,
58+
promptOverride = override,
59+
)
60+
out
61+
},
62+
execution = { input ->
63+
val promptOverride = teacher.invokeSuspend(input)
64+
student.invokeSuspendWithPromptOverride(input, promptOverride)
65+
},
66+
)
5267
}

src/main/kotlin/agents_engine/core/Agent.kt

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,14 @@ class Agent<IN, OUT>(
268268
internal suspend fun invokeSuspendForSession(
269269
input: IN,
270270
emitter: agents_engine.model.AgentEventEmitter? = null,
271+
/**
272+
* #1747 — optional system-prompt override (used by the `wrap` operator).
273+
* When non-null, replaces `this.prompt` as the effective system prompt
274+
* for this invocation only. Consolidates the previous separate
275+
* `invokeSuspendWithPromptOverride` entry point — that one now
276+
* delegates here with `emitter = null`.
277+
*/
278+
promptOverride: String? = null,
271279
onSkillCompleted: (agents_engine.model.TokenUsage?) -> Unit = { /* no-op */ },
272280
onSkillStarted: (String) -> Unit,
273281
): OUT {
@@ -276,7 +284,11 @@ class Agent<IN, OUT>(
276284
skillChosenListener?.invoke(skill.name)
277285
onSkillStarted(skill.name)
278286
return if (skill.isAgentic) {
279-
val result = executeAgentic(this, skill, input, emitter = emitter)
287+
val result = executeAgentic(
288+
this, skill, input,
289+
effectivePrompt = promptOverride ?: this.prompt,
290+
emitter = emitter,
291+
)
280292
// #1740: surface cumulative usage on the way out. Non-agentic
281293
// skills don't go through executeAgentic, so onSkillCompleted
282294
// stays at its default null for the implementedBy path below.
@@ -320,22 +332,15 @@ class Agent<IN, OUT>(
320332
* differs (reads the override instead of `agent.prompt`).
321333
*/
322334
internal suspend fun invokeSuspendWithPromptOverride(input: IN, promptOverride: String): OUT {
323-
try {
324-
val skill = resolveSkill(input)
325-
skillChosenListener?.invoke(skill.name)
326-
return if (skill.isAgentic) {
327-
castOut(executeAgentic(this, skill, input, effectivePrompt = promptOverride).output)
328-
} else {
329-
// Non-agentic skills don't read prompt — implementedBy lambdas
330-
// ignore the override. Same behavior as the legacy path.
331-
castOut(executors[skill.name]!!(input))
332-
}
333-
} catch (t: Throwable) {
334-
errorListener?.let { listener ->
335-
try { listener(t) } catch (cb: Throwable) { t.addSuppressed(cb) }
336-
}
337-
throw t
338-
}
335+
// #1747 — consolidated into invokeSuspendForSession. emitter = null
336+
// preserves the non-streaming behavior the wrap operator used pre-
337+
// step 4; the streaming variant goes through runAgentInSession with
338+
// the same promptOverride parameter.
339+
return invokeSuspendForSession(
340+
input = input,
341+
emitter = null,
342+
promptOverride = promptOverride,
343+
) { /* no-op onSkillStarted */ }
339344
}
340345

341346
private suspend fun resolveSkill(input: IN): Skill<*, *> {

src/main/kotlin/agents_engine/runtime/events/AgentSessionExtension.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,20 @@ internal suspend fun <IN, OUT> runAgentInSession(
8181
agent: Agent<IN, OUT>,
8282
input: IN,
8383
emitter: AgentEventEmitter,
84+
/**
85+
* #1747 — when non-null, runs the agentic loop with this string as
86+
* the effective system prompt instead of `agent.prompt`. Used by the
87+
* `wrap` operator's session path (teacher's output becomes the
88+
* student's per-call system prompt).
89+
*/
90+
promptOverride: String? = null,
8491
): Pair<OUT, TokenUsage?> {
8592
var capturedSkillName: String? = null
8693
var capturedUsage: TokenUsage? = null
8794
val output = agent.invokeSuspendForSession(
8895
input,
8996
emitter = emitter,
97+
promptOverride = promptOverride,
9098
onSkillCompleted = { usage -> capturedUsage = usage },
9199
) { skillName ->
92100
// SkillStarted fires BEFORE the skill body runs — emitting from
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package agents_engine.runtime.events
2+
3+
import agents_engine.composition.pipeline.session
4+
import agents_engine.composition.wrap.wrap
5+
import agents_engine.core.agent
6+
import agents_engine.model.LlmMessage
7+
import agents_engine.model.LlmResponse
8+
import agents_engine.model.ModelClient
9+
import kotlinx.coroutines.flow.toList
10+
import kotlinx.coroutines.test.runTest
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
import kotlin.test.assertIs
14+
import kotlin.test.assertTrue
15+
16+
// #1747 — wrap (teacher wrap student) returns a Pipeline that runs
17+
// teacher first to produce a system-prompt String, then runs student
18+
// under that override. Both agents' events stream through to the
19+
// session's events Flow with their own agentIds.
20+
21+
class WrapSessionTest {
22+
23+
@Test
24+
fun `wrap session emits ordered events from teacher then student with student-prompt-override active`() = runTest {
25+
// Teacher emits a system-prompt string for the student.
26+
// Student is agentic — uses a stub ModelClient so the prompt-override
27+
// path through executeAgentic is exercised under streaming.
28+
val capturedSystemPrompt = mutableListOf<String>()
29+
val stub = ModelClient { messages: List<LlmMessage> ->
30+
messages.firstOrNull { it.role == "system" }?.content?.let { capturedSystemPrompt += it }
31+
LlmResponse.Text("student-output")
32+
}
33+
34+
val teacher = agent<String, String>("teacher") {
35+
skills {
36+
skill<String, String>("write-prompt", "Emits a system prompt for the student") {
37+
implementedBy { input -> "Teacher emitted prompt for: $input" }
38+
}
39+
}
40+
}
41+
val student = agent<String, String>("student") {
42+
prompt("Baked-in student prompt that should NOT be used during wrap.")
43+
model { ollama("llama3"); client = stub }
44+
skills {
45+
skill<String, String>("do-task", "Does the actual task via LLM") { tools() }
46+
}
47+
}
48+
val pipeline = teacher wrap student
49+
50+
val session = pipeline.session("hello")
51+
val events = session.events.toList()
52+
val output = session.await()
53+
54+
assertEquals("student-output", output, "wrap pipeline output is student's output")
55+
56+
// Teacher's events come first.
57+
val firstStarted = events.filterIsInstance<AgentEvent.SkillStarted>().first()
58+
assertEquals("teacher", firstStarted.agentId)
59+
assertEquals("write-prompt", firstStarted.skillName)
60+
61+
// Student's events follow.
62+
val studentStarted = events.filterIsInstance<AgentEvent.SkillStarted>()
63+
.firstOrNull { it.agentId == "student" }
64+
?: error("expected SkillStarted(student); got: $events")
65+
assertEquals("do-task", studentStarted.skillName)
66+
67+
// Order: teacher.SkillStarted < teacher.SkillCompleted < student.SkillStarted
68+
val teacherStarted = events.indexOfFirst { it is AgentEvent.SkillStarted && it.agentId == "teacher" }
69+
val teacherCompleted = events.indexOfFirst { it is AgentEvent.SkillCompleted && it.agentId == "teacher" }
70+
val studentStartedIdx = events.indexOfFirst { it is AgentEvent.SkillStarted && it.agentId == "student" }
71+
assertTrue(teacherStarted < teacherCompleted, "teacher.SkillStarted < teacher.SkillCompleted")
72+
assertTrue(teacherCompleted < studentStartedIdx, "teacher.SkillCompleted < student.SkillStarted")
73+
74+
// Terminal Completed uses the student's name.
75+
val terminal = events.last()
76+
assertIs<AgentEvent.Completed<String>>(terminal)
77+
assertEquals("student", terminal.agentId, "Completed.agentId = student's name (last agent in chain)")
78+
assertEquals("student-output", terminal.output)
79+
80+
// Prompt override was active: the system prompt the student's LLM saw must contain
81+
// the teacher's emitted text, NOT the student's baked-in prompt.
82+
assertTrue(capturedSystemPrompt.isNotEmpty(), "student's stub ModelClient must have received a system message")
83+
val systemPrompt = capturedSystemPrompt.single()
84+
assertTrue(
85+
"Teacher emitted prompt for: hello" in systemPrompt,
86+
"student's system prompt must carry the teacher's output verbatim; got: \"$systemPrompt\"",
87+
)
88+
assertTrue(
89+
"Baked-in student prompt" !in systemPrompt,
90+
"student's baked-in prompt must NOT appear when wrap is active; got: \"$systemPrompt\"",
91+
)
92+
}
93+
}

0 commit comments

Comments
 (0)