242 lines
9.4 KiB
Swift
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))
|
|
}
|
|
}
|