Files
nucleic/Sources/NucleicCore/ContextSwitchHandover.swift
T

242 lines
9.4 KiB
Swift

import Foundation
/// Portable context handed to the incoming engine. The provenance is retained in memory so the
/// coordinator and tests can distinguish an agent-authored brief from the deterministic fallback;
/// the Markdown itself remains harness-neutral.
public struct HandoverBrief: Sendable, Equatable {
public enum Provenance: Sendable, Equatable {
case outgoingAgent(model: String)
case digest
}
public var markdown: String
public var provenance: Provenance
public init(markdown: String, provenance: Provenance) {
self.markdown = markdown
self.provenance = provenance
}
}
/// Pluggable authorship seam for Context Switch. Detection, UI, and engine swapping depend only on
/// the resulting portable brief, so a future side summarizer can replace the outgoing-agent turn
/// without changing the rest of the flow.
public protocol HandoverComposing: Sendable {
func compose(
session: Session,
controller: SessionController,
events: [AgentEvent],
target: ContextSwitchTarget
) async -> HandoverBrief
}
/// Canonical machine-authored prompt for the outgoing agent's visible sign-off turn.
public enum ContextSwitchHandoverPrompt {
public static func brief(for target: ContextSwitchTarget) -> String {
"""
[Nucleic] You are handing this work over to a different AI agent \
(\(target.resolution.model)) that has no access to this conversation. Write a complete \
handover brief in plain Markdown. Do not use any tools — write only from what you already \
know. Cover: (1) the original goal and the current state; (2) what was done — files \
created/changed, by path, and why; (3) key decisions and their rationale, and approaches \
you ruled out; (4) known issues, failing tests, and dead ends; (5) the exact next steps; \
(6) anything the next agent must not redo or break. Plain portable prose only: no tool \
names, session IDs, internal reasoning fragments, or references to this interface.
"""
}
}
/// Instant, deterministic fallback used whenever the outgoing agent cannot safely author a brief.
/// It is intentionally useful on its own: callers may choose this composer directly for sessions
/// that cannot run another turn.
public struct DigestHandover: HandoverComposing {
/// Current session-associated todo text known by the coordinator. Core has no global todo
/// registry, so the app injects these structured facts when available.
public var todoItems: [String]
public init(todoItems: [String] = []) {
self.todoItems = todoItems
}
public func compose(
session: Session,
controller _: SessionController,
events: [AgentEvent],
target: ContextSwitchTarget
) async -> HandoverBrief {
Self.brief(session: session, events: events, target: target, todoItems: todoItems)
}
public static func brief(
session: Session,
events: [AgentEvent],
target: ContextSwitchTarget,
todoItems: [String] = []
) -> HandoverBrief {
let userRequests = events.compactMap { event -> String? in
guard case .userText(let chunk) = event.kind else { return nil }
let text = chunk.text.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
let originalGoal = userRequests.first ?? session.title
let digest = HeuristicSummary.sessionDigest(events)
let lastTurn = HeuristicSummary.lastTurn(events)
var changedFiles: [(path: String, kind: FileChange.ChangeKind)] = []
var seenPaths = Set<String>()
for event in events.reversed() {
guard case .fileChange(let change) = event.kind,
seenPaths.insert(change.path).inserted
else { continue }
changedFiles.append((change.path, change.kind))
}
changedFiles.reverse()
var lines = [
"# Handover brief",
"",
"> Auto-generated digest (the previous agent could not write a brief).",
"",
"## Original goal and current state",
"",
originalGoal,
"",
"The chat is switching from \(target.fromPurpose.displayName) to "
+ "\(target.resolution.purpose.displayName), targeting "
+ "`\(target.resolution.model)` at `\(target.resolution.effort)` effort.",
"",
digest,
"",
"## Files changed",
"",
]
if changedFiles.isEmpty {
lines.append("No per-file change events were recorded.")
} else {
for file in changedFiles {
lines.append("- `\(file.path)` — \(file.kind.rawValue)")
}
}
if let stat = session.diffStat {
lines.append("")
lines.append(
"Recorded diffstat: \(stat.filesChanged) files, +\(stat.added)/-\(stat.removed).")
}
lines += ["", "## Current to-do state", ""]
if todoItems.isEmpty {
lines.append("No session-associated open to-do items were recorded.")
} else {
for item in todoItems { lines.append("- \(item)") }
}
lines += ["", "## Most recent settled turn", ""]
if let request = lastTurn.request?.trimmingCharacters(in: .whitespacesAndNewlines),
!request.isEmpty
{
lines.append("Latest request: \(request)")
}
if let reply = lastTurn.reply?.trimmingCharacters(in: .whitespacesAndNewlines),
!reply.isEmpty
{
lines.append("")
lines.append("Latest agent reply: \(reply)")
}
lines += [
"",
"## Next steps",
"",
"Continue from the current repository state, verify this digest against the code, "
+ "and complete the newest request without redoing work already reflected in the diff.",
]
return HandoverBrief(markdown: lines.joined(separator: "\n"), provenance: .digest)
}
}
/// v1 handover author: one cache-hot, tool-free turn on the outgoing engine, guarded by a strict
/// abort ladder. Every failure returns the deterministic digest; callers never receive an empty
/// brief or need to understand why generation failed.
public struct OutgoingAgentHandover: HandoverComposing {
private enum MonitorResult: Sendable {
case completed(String)
case abort
}
public var timeoutSeconds: TimeInterval
public var interruptGraceSeconds: TimeInterval
private let fallback: any HandoverComposing
public init(
fallback: any HandoverComposing = DigestHandover(),
timeoutSeconds: TimeInterval = 120,
interruptGraceSeconds: TimeInterval = 5
) {
self.fallback = fallback
self.timeoutSeconds = timeoutSeconds
self.interruptGraceSeconds = interruptGraceSeconds
}
public func compose(
session: Session,
controller: SessionController,
events: [AgentEvent],
target: ContextSwitchTarget
) async -> HandoverBrief {
let currentEvents = await controller.transcriptSoFar()
let baseline = currentEvents.count
do {
try await controller.sendInput(
AgentInput(text: ContextSwitchHandoverPrompt.brief(for: target)),
echoToTranscript: false)
} catch {
return await fallback.compose(
session: session, controller: controller, events: events, target: target)
}
let stream = await controller.subscribe(afterHistoryCount: baseline)
let monitor = Task<MonitorResult, Never> {
var finalReply: String?
for await event in stream {
if Task.isCancelled { return .abort }
switch event.kind {
case .assistantText(let chunk) where !chunk.isPartial:
let text = chunk.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { finalReply = text }
case .toolCallStarted, .approvalRequested:
return .abort
case .runFinished(let finished):
guard finished.outcome == .completed else { return .abort }
let reply = finalReply
?? finished.finalText?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let reply, !reply.isEmpty else { return .abort }
return .completed(reply)
default:
continue
}
}
return .abort
}
let result = await withTimeout(timeoutSeconds) { await monitor.value }
guard let result, case .completed(let markdown) = result else {
monitor.cancel()
await controller.interrupt()
_ = await withTimeout(interruptGraceSeconds) {
await controller.join()
return true
}
return await fallback.compose(
session: session,
controller: controller,
events: await controller.transcriptSoFar(),
target: target)
}
await controller.join()
return HandoverBrief(
markdown: markdown,
provenance: .outgoingAgent(model: session.model ?? session.backend.rawValue))
}
}