fix: implement stage_complete tool for LLM-driven stage completion
- Add STAGE_COMPLETE_TOOL constant and meta-tool handler in executeStage loop: when LLM calls stage_complete, break out of tool dispatch loop and proceed to normal validation/transition - Inject stage_complete tool definition in every inference request so the LLM always has the option to signal stage completion - Replace expand-all diff with scrollable diff window in approval surface (ctrl+x toggles, up/down scrolls, shows line range)
This commit is contained in:
@@ -27,6 +27,7 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph {
|
||||
val pendingDecision = state.pendingDecision
|
||||
|
||||
val diffExpanded = state.diffExpanded
|
||||
val scrollOffset = state.diffScrollOffset
|
||||
|
||||
val lines = buildList<Line> {
|
||||
if (approval == null) {
|
||||
@@ -45,8 +46,11 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph {
|
||||
if (preview != null) {
|
||||
val previewLines = preview.lines()
|
||||
if (previewLines.isNotEmpty()) {
|
||||
val totalLines = previewLines.size
|
||||
val visibleLines = if (diffExpanded) {
|
||||
previewLines
|
||||
val from = scrollOffset.coerceIn(0, maxOf(0, totalLines - 1))
|
||||
val to = minOf(from + PREVIEW_MAX_LINES, totalLines)
|
||||
previewLines.subList(from, to)
|
||||
} else {
|
||||
previewLines.take(PREVIEW_MAX_LINES)
|
||||
}
|
||||
@@ -69,12 +73,13 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph {
|
||||
add(Line.from(Span.raw(" $line")))
|
||||
}
|
||||
}
|
||||
if (!diffExpanded && previewLines.size > PREVIEW_MAX_LINES) {
|
||||
val remaining = previewLines.size - PREVIEW_MAX_LINES
|
||||
add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand)", dimStyle)))
|
||||
if (!diffExpanded && totalLines > PREVIEW_MAX_LINES) {
|
||||
val remaining = totalLines - PREVIEW_MAX_LINES
|
||||
add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand, \u2191\u2193 to scroll)", dimStyle)))
|
||||
}
|
||||
if (diffExpanded) {
|
||||
add(Line.from(Span.styled(" (full diff — ctrl+x to collapse)", dimStyle)))
|
||||
val showingTo = minOf(scrollOffset + PREVIEW_MAX_LINES, totalLines)
|
||||
add(Line.from(Span.styled(" lines ${scrollOffset + 1}\u2013$showingTo of $totalLines — \u2191\u2193 scroll, ctrl+x collapse", dimStyle)))
|
||||
}
|
||||
} else {
|
||||
add(Line.from(Span.styled(" no preview available", dimStyle)))
|
||||
|
||||
@@ -72,6 +72,8 @@ object KeyResolver {
|
||||
KeyEvent.Enter -> Action.SubmitApprovalDecision
|
||||
KeyEvent.Escape -> Action.CancelInput
|
||||
KeyEvent.Cancel -> Action.CancelInput
|
||||
KeyEvent.NavUp -> Action.NavigateUp
|
||||
KeyEvent.NavDown -> Action.NavigateDown
|
||||
KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList
|
||||
KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip
|
||||
KeyEvent.ToggleDiffExpand -> Action.ToggleDiffExpand
|
||||
|
||||
@@ -118,7 +118,10 @@ object RootReducer {
|
||||
else withBgReset.copy(eventStripVisible = !withBgReset.eventStripVisible)
|
||||
}
|
||||
|
||||
is Action.ToggleDiffExpand -> withBgReset.copy(diffExpanded = !withBgReset.diffExpanded)
|
||||
is Action.ToggleDiffExpand -> withBgReset.copy(
|
||||
diffExpanded = !withBgReset.diffExpanded,
|
||||
diffScrollOffset = 0,
|
||||
)
|
||||
|
||||
// Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1),
|
||||
// or enter a selected session from the list in IDLE.
|
||||
@@ -188,9 +191,11 @@ object RootReducer {
|
||||
}
|
||||
}
|
||||
|
||||
// Input history navigation on NavigateUp in IN_SESSION + ROUTER (ARCH-HISTORY-2/3).
|
||||
// Diff scrolling in APPROVAL mode when expanded.
|
||||
is Action.NavigateUp -> {
|
||||
if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) {
|
||||
if (withBgReset.displayState == DisplayState.APPROVAL && withBgReset.diffExpanded) {
|
||||
withBgReset.copy(diffScrollOffset = maxOf(0, withBgReset.diffScrollOffset - 1))
|
||||
} else if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) {
|
||||
val selectedId = withBgReset.sessions.selectedId
|
||||
if (selectedId != null) {
|
||||
val history = withBgReset.inputHistory[selectedId] ?: emptyList()
|
||||
@@ -227,9 +232,11 @@ object RootReducer {
|
||||
}
|
||||
}
|
||||
|
||||
// Input history navigation on NavigateDown in IN_SESSION + ROUTER (ARCH-HISTORY-2/3).
|
||||
// Diff scrolling in APPROVAL mode when expanded.
|
||||
is Action.NavigateDown -> {
|
||||
if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) {
|
||||
if (withBgReset.displayState == DisplayState.APPROVAL && withBgReset.diffExpanded) {
|
||||
withBgReset.copy(diffScrollOffset = withBgReset.diffScrollOffset + 1)
|
||||
} else if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) {
|
||||
val selectedId = withBgReset.sessions.selectedId
|
||||
if (selectedId != null) {
|
||||
val history = withBgReset.inputHistory[selectedId] ?: emptyList()
|
||||
|
||||
@@ -48,6 +48,7 @@ data class TuiState(
|
||||
val routerConnected: Boolean = false,
|
||||
val routerMessages: Map<String, List<String>> = emptyMap(),
|
||||
val diffExpanded: Boolean = false,
|
||||
val diffScrollOffset: Int = 0,
|
||||
val chatMode: ChatMode = ChatMode.CHAT,
|
||||
/**
|
||||
* True from connect until SnapshotComplete observed. Reset to true on Disconnected.
|
||||
|
||||
+16
-1
@@ -92,6 +92,7 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
@@ -99,6 +100,7 @@ import java.util.concurrent.atomic.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val MAX_TOOL_ROUNDS = 10
|
||||
private const val STAGE_COMPLETE_TOOL = "stage_complete"
|
||||
|
||||
@SuppressWarnings(
|
||||
"ForbiddenComment",
|
||||
@@ -258,6 +260,12 @@ abstract class SessionOrchestrator(
|
||||
inferenceResult.response.finishReason is FinishReason.ToolCall &&
|
||||
toolRounds < MAX_TOOL_ROUNDS
|
||||
) {
|
||||
// stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met.
|
||||
// Skip tool dispatch — the loop exits and normal validation/transition follows
|
||||
// (emitToolArtifacts + verifyProduces run on the success path after the loop).
|
||||
if (inferenceResult.response.toolCalls.any { it.function.name == STAGE_COMPLETE_TOOL }) {
|
||||
break
|
||||
}
|
||||
val toolEntries = dispatchToolCalls(sessionId, stageId,
|
||||
inferenceResult.response.toolCalls, stageConfig)
|
||||
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
|
||||
@@ -670,7 +678,14 @@ abstract class SessionOrchestrator(
|
||||
parameters = tool.parametersSchema,
|
||||
),
|
||||
)
|
||||
},
|
||||
} + ToolDefinition(
|
||||
function = ToolFunction(
|
||||
name = STAGE_COMPLETE_TOOL,
|
||||
description = "Call this tool when the stage's goal is fully met and no further tool calls are needed. " +
|
||||
"The orchestrator will proceed to the next stage.",
|
||||
parameters = JsonObject(emptyMap()),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// TODO: capture rendered prompt; currently contextpack.toString()
|
||||
|
||||
Reference in New Issue
Block a user