Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Added

- `<provider>/<model>` convenience for known llm providers, for example `cds.requires.llm: anthropic/haiku`
- Experimental Thinking steps in the preview

### Fixed

Expand Down
129 changes: 103 additions & 26 deletions lib/preview/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,50 @@
background: #0f2d45;
}
}

/* Thinking summary: per-turn reasoning text superseded by the final
answer. Rendered as a collapsible <details> strip above the current
streaming bubble. Auto-collapses at task completion. */
.thinking-summary {
margin: 4px 0 8px 0;
font-size: 0.88em;
color: #666;
border-left: 2px solid #ccc;
padding-left: 10px;
}
.thinking-summary summary {
cursor: pointer;
user-select: none;
color: #888;
list-style: none;
}
.thinking-summary summary::before {
content: "▶ ";
font-size: 0.8em;
}
.thinking-summary[open] summary::before {
content: "▼ ";
}
.thinking-step {
padding: 4px 0;
font-style: italic;
}
.thinking-step + .thinking-step {
border-top: 1px dashed #e0e0e0;
margin-top: 4px;
}
@media (prefers-color-scheme: dark) {
.thinking-summary {
color: #999;
border-left-color: #444;
}
.thinking-summary summary {
color: #aaa;
}
.thinking-step + .thinking-step {
border-top-color: #333;
}
}
</style>
<script src="marked.min.js"></script>
</head>
Expand All @@ -406,12 +450,11 @@
<h1>{{agentName}}</h1>
<label
class="header-toggle"
title="Stream tokens as they arrive"
title="Stream the agent's reasoning steps while it runs (experimental)"
style="margin-left: auto"
hidden
>
<input type="checkbox" id="streamToggle" hidden />
Streaming
<input type="checkbox" id="streamToggle" />
Show thinking (experimental)
</label>
<div class="status-dot" id="dot"></div>
</header>
Expand Down Expand Up @@ -444,9 +487,10 @@ <h1>{{agentName}}</h1>
let pendingTaskId = null
let activeTaskId = null
let activeAbort = null
// Live streaming bubble: created on first token, updated incrementally
let streamingBubble = null
let streamingText = ""
let thinkingSummaryEl = null
let thinkingStepCount = 0

const HISTORY_KEY = "chatHistory_" + AGENT_URL
let inputHistory = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]")
Expand Down Expand Up @@ -506,6 +550,26 @@ <h1>{{agentName}}</h1>
return el
}

function addThinkingStep(text) {
if (!thinkingSummaryEl) {
thinkingSummaryEl = document.createElement("details")
thinkingSummaryEl.className = "thinking-summary"
thinkingSummaryEl.open = true
const summary = document.createElement("summary")
summary.textContent = "Thinking (0 steps)"
thinkingSummaryEl.appendChild(summary)
messages.insertBefore(thinkingSummaryEl, typing)
}
thinkingStepCount++
const step = document.createElement("div")
step.className = "thinking-step"
step.innerHTML = md(text)
thinkingSummaryEl.appendChild(step)
thinkingSummaryEl.querySelector("summary").textContent =
`Thinking (${thinkingStepCount} step${thinkingStepCount === 1 ? "" : "s"})`
messages.scrollTop = messages.scrollHeight
}

function addApprovalPrompt(description) {
const el = document.createElement("div")
el.className = "msg approval"
Expand Down Expand Up @@ -557,9 +621,6 @@ <h1>{{agentName}}</h1>
activeTaskId = null
activeAbort = null
statusText.textContent = ""
// Reset streaming state on any transition to idle
streamingBubble = null
streamingText = ""
}
messages.scrollTop = messages.scrollHeight
}
Expand All @@ -573,8 +634,6 @@ <h1>{{agentName}}</h1>

const abort = new AbortController()
activeAbort = abort
streamingBubble = null
streamingText = ""

try {
const resp = await fetch(AGENT_URL, {
Expand Down Expand Up @@ -623,6 +682,12 @@ <h1>{{agentName}}</h1>
pendingTaskId = taskId
const msgParts = result?.status?.message?.parts ?? []
const description = partsToText(msgParts)
if (streamingBubble && streamingText) {
addThinkingStep(streamingText)
streamingBubble.remove()
streamingBubble = null
streamingText = ""
}
addApprovalPrompt(description)
return
}
Expand Down Expand Up @@ -669,6 +734,8 @@ <h1>{{agentName}}</h1>
activeAbort = abort
streamingBubble = null
streamingText = ""
thinkingSummaryEl = null
thinkingStepCount = 0

const useStream = streamToggle.checked
try {
Expand Down Expand Up @@ -779,7 +846,6 @@ <h1>{{agentName}}</h1>
return null
}

// Incremental artifact-update: render tokens as they arrive
if (event.kind === "artifact-update" && streamToggle.checked) {
const parts = event.artifact?.parts ?? []
const text = partsToText(parts)
Expand All @@ -788,35 +854,46 @@ <h1>{{agentName}}</h1>
const lastChunk = event.lastChunk ?? false

if (lastChunk) {
// lastChunk carries the authoritative full text — replace accumulated content.
// Terminal: current bubble = final answer. Replace with authoritative text.
if (text) {
if (streamingBubble) {
streamingBubble.innerHTML = md(text)
messages.scrollTop = messages.scrollHeight
} else {
typing.style.display = "none"
addMessage("agent", text)
streamingBubble = addMessage("agent", text)
}
}
if (thinkingSummaryEl) thinkingSummaryEl.open = false
streamingBubble = null
streamingText = text // keep so handleResult skips duplicate render
streamingText = text // dedupe with handleResult
return null
}

if (text) {
if (!append || !streamingBubble) {
// First token — create a live bubble, hide typing indicator
typing.style.display = "none"
streamingBubble = addMessage("agent", text)
streamingText = text
} else {
// Subsequent token — accumulate and re-render markdown
streamingText += text
streamingBubble.innerHTML = md(streamingText)
messages.scrollTop = messages.scrollHeight
if (!text) return null

if (!append) {
// New turn boundary — move the previous turn's text (if any) into
// the thinking summary and drop its bubble from the DOM. Open a
// fresh bubble for the new turn.
if (streamingBubble && streamingText) {
addThinkingStep(streamingText)
streamingBubble.remove()
}
typing.style.display = "none"
streamingBubble = addMessage("agent", text)
streamingText = text
} else if (streamingBubble) {
// Subsequent token — accumulate into the current turn's bubble.
streamingText += text
streamingBubble.innerHTML = md(streamingText)
} else {
// Defensive: append:true with no bubble yet (unexpected). Open one.
typing.style.display = "none"
streamingBubble = addMessage("agent", text)
streamingText = text
}

messages.scrollTop = messages.scrollHeight
return null
}

Expand Down
37 changes: 6 additions & 31 deletions srv/handlers/graph-executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,16 +312,13 @@ class GraphExecutor {

let tokenCount = 0
let finalState = null
// Track the current turn (langchain message id) and whether it has emitted a
// tool call. Anthropic-style turns can stream a text preamble BEFORE their
// tool_use block ("Let me first look up …"); we can't tell in advance that
// such a turn is planning rather than the final answer, so we stream those
// tokens optimistically. Once we see tool_call_chunks for the same turn, we
// know retrospectively that the preamble was planning — emit an authoritative
// event-level replace with empty text to wipe the leaked preamble, then skip
// all further text from this turn.
// Track the current turn (langchain message id). Each new turn opens a fresh
// bubble on the client via `append:false`; subsequent tokens of the same turn
// are `append:true` (accumulate). Anthropic-style turns can stream a text
// preamble BEFORE their tool_use block ("Let me first look up …") — we let
// that reasoning text stream normally; the client is responsible for the
// final visual (collapse to the last turn's bubble at task completion).
let currentMsgId = null
let turnHasToolCall = false

try {
if (typeof graph.stream !== "function" || cds.env.agents?.streaming === false) {
Expand Down Expand Up @@ -357,31 +354,9 @@ class GraphExecutor {

if (msgChunk.id && msgChunk.id !== currentMsgId) {
currentMsgId = msgChunk.id
turnHasToolCall = false
tokenCount = 0
}

// Retroactively invalidate a leaked planning preamble. In a ReAct loop
// the model can emit "Let me look this up …" before its tool_use block
if (msgChunk.tool_call_chunks?.length && !turnHasToolCall) {
turnHasToolCall = true
if (tokenCount > 0) {
eventBus.publish({
kind: "artifact-update",
taskId,
contextId,
append: false,
lastChunk: false,
artifact: {
artifactId: "response",
parts: [{ kind: "text", text: "" }],
},
})
tokenCount = 0
}
}
if (turnHasToolCall) continue

const text = messageText(msgChunk?.content)
if (!text) continue
// A2A TaskArtifactUpdateEvent: `append` and `lastChunk` are event-level
Expand Down