server-side block IDs for stable bookmark/plumb targets

- Add blockCounter to Agent, generating sequential hex IDs
- Include #blockId in all block headers: [[[role:name#id]]]
- GUI parses server-provided IDs from headers
- Falls back to client-generated IDs for legacy blocks without server IDs
- Block IDs are now deterministic based on chat history order
This commit is contained in:
Levi Neely 2026-10-09 16:12:26 +02:00
parent 233a5cc2e9
commit 46aa901763
7 changed files with 113 additions and 35 deletions

View File

@ -76,6 +76,7 @@ type Agent struct {
chatSignalMu sync.Mutex chatSignalMu sync.Mutex
chatSignalCh chan struct{} // closed on new chat data; replaced with fresh channel chatSignalCh chan struct{} // closed on new chat data; replaced with fresh channel
plan []byte // agent's plan file contents plan []byte // agent's plan file contents
blockCounter uint64 // monotonic counter for generating stable block IDs
} }
// Backend returns the active backend from the runtime. // Backend returns the active backend from the runtime.

View File

@ -9,12 +9,20 @@ package agent
import ( import (
"fmt" "fmt"
"sync" "sync"
"sync/atomic"
) )
// --- Chat log methods --- // --- Chat log methods ---
const maxChatLogBytes = 16 * 1024 * 1024 const maxChatLogBytes = 16 * 1024 * 1024
// NextBlockID returns the next block ID and increments the counter.
// Block IDs are sequential integers formatted as hex strings.
func (ag *Agent) NextBlockID() string {
id := atomic.AddUint64(&ag.blockCounter, 1) - 1
return fmt.Sprintf("%x", id)
}
// AppendChat appends data to the chat log and notifies stream readers. // AppendChat appends data to the chat log and notifies stream readers.
func (ag *Agent) AppendChat(data []byte) { func (ag *Agent) AppendChat(data []byte) {
if len(data) == 0 { if len(data) == 0 {

View File

@ -7,6 +7,8 @@
package agent package agent
import ( import (
"fmt"
"ollie/cmd/olliesrv/internal/backend" "ollie/cmd/olliesrv/internal/backend"
"ollie/format" "ollie/format"
) )
@ -17,6 +19,7 @@ func (ag *Agent) initChatHandler() {
var streamingRole string var streamingRole string
var streamingResponseID string var streamingResponseID string
var streamingFence string var streamingFence string
var streamingBlockID string
ag.output = func(ev Event) { ag.output = func(ev Event) {
switch ev.Role { switch ev.Role {
@ -29,15 +32,18 @@ func (ag *Agent) initChatHandler() {
} }
ag.AppendChat([]byte("\n" + format.EndTag + "\n")) ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
} }
blockID := ag.NextBlockID()
if ev.Role == "assistant" && ev.ResponseID != "" { if ev.Role == "assistant" && ev.ResponseID != "" {
ag.AppendChat([]byte(format.AssistantHeaderPrefix + ev.ResponseID + "]]]\n")) // Include both responseID and blockID: [[[assistant:responseID#blockID]]]
ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", ev.ResponseID, blockID)))
streamingResponseID = ev.ResponseID streamingResponseID = ev.ResponseID
} else { } else {
ag.AppendChat([]byte(format.RoleDelim(ev.Role) + "\n")) ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID)))
} }
streamingRole = ev.Role streamingRole = ev.Role
streamingBlockID = blockID
} }
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat)) ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
case "reasoning": case "reasoning":
if streamingRole != ev.Role { if streamingRole != ev.Role {
@ -48,10 +54,12 @@ func (ag *Agent) initChatHandler() {
} }
ag.AppendChat([]byte("\n" + format.EndTag + "\n")) ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
} }
ag.AppendChat([]byte(format.RoleDelim(ev.Role) + "\n")) blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n", blockID)))
streamingRole = ev.Role streamingRole = ev.Role
streamingBlockID = blockID
} }
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat)) ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
case "tool": case "tool":
if streamingRole == "tool" { if streamingRole == "tool" {
@ -76,11 +84,15 @@ func (ag *Agent) initChatHandler() {
streamingRole = "" streamingRole = ""
} }
if ev.Content == "" { if ev.Content == "" {
ag.AppendChat([]byte(format.ToolDelim(ev.Name) + "```" + ev.OutputFormat + "\n")) // Streaming tool output: write header with blockID
blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[tool:%s#%s]]]\n```%s\n", ev.Name, blockID, ev.OutputFormat)))
streamingFence = format.ToolFence streamingFence = format.ToolFence
streamingRole = "tool" streamingRole = "tool"
streamingBlockID = blockID
} else { } else {
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat)) // Complete tool output in one event
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
} }
case "retry": case "retry":
@ -92,9 +104,11 @@ func (ag *Agent) initChatHandler() {
ag.AppendChat([]byte("\n" + format.EndTag + "\n")) ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = "" streamingRole = ""
} }
ag.AppendChat([]byte(format.RetryTag + "\n")) blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[retry#%s]]]\n", blockID)))
streamingRole = "retry" streamingRole = "retry"
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat)) streamingBlockID = blockID
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ""))
case "call": case "call":
if streamingRole != "" { if streamingRole != "" {
@ -105,7 +119,7 @@ func (ag *Agent) initChatHandler() {
ag.AppendChat([]byte("\n" + format.EndTag + "\n")) ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = "" streamingRole = ""
} }
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat)) ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
default: default:
if streamingRole != "" { if streamingRole != "" {
@ -116,8 +130,9 @@ func (ag *Agent) initChatHandler() {
ag.AppendChat([]byte("\n" + format.EndTag + "\n")) ag.AppendChat([]byte("\n" + format.EndTag + "\n"))
streamingRole = "" streamingRole = ""
} }
ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat)) ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID()))
} }
_ = streamingBlockID // suppress unused warning (available for future use)
} }
} }
@ -146,29 +161,31 @@ func (ag *Agent) ReplayMessages(messages []backend.Message) {
switch m.Role { switch m.Role {
case "system": case "system":
case "user": case "user":
ag.AppendChat(format.FormatEvent("user", "", m.Content, "")) ag.AppendChat(format.FormatEvent("user", "", m.Content, "", ag.NextBlockID()))
case "assistant": case "assistant":
if m.Reasoning != "" { if m.Reasoning != "" {
ag.AppendChat([]byte(format.RoleDelim("reasoning") + "\n" + m.Reasoning + "\n" + format.EndTag + "\n")) blockID := ag.NextBlockID()
ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n%s\n%s\n", blockID, m.Reasoning, format.EndTag)))
} }
blockID := ag.NextBlockID()
if m.ID != "" { if m.ID != "" {
ag.AppendChat([]byte(format.AssistantHeaderPrefix + m.ID + "]]]\n")) ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", m.ID, blockID)))
} else { } else {
ag.AppendChat([]byte(format.AssistantTag + "\n")) ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID)))
} }
if m.Content != "" { if m.Content != "" {
ag.AppendChat([]byte(m.Content + "\n")) ag.AppendChat([]byte(m.Content + "\n"))
} }
ag.AppendChat([]byte(format.EndTag + "\n")) ag.AppendChat([]byte(format.EndTag + "\n"))
for _, tc := range m.ToolCalls { for _, tc := range m.ToolCalls {
ag.AppendChat(format.FormatEvent("call", tc.Name, string(tc.Arguments), "")) ag.AppendChat(format.FormatEvent("call", tc.Name, string(tc.Arguments), "", ag.NextBlockID()))
} }
case "tool": case "tool":
name := callNames[m.ToolCallID] name := callNames[m.ToolCallID]
if name == "" { if name == "" {
name = m.ToolCallID name = m.ToolCallID
} }
ag.AppendChat(format.FormatEvent("tool", name, m.Content, "")) ag.AppendChat(format.FormatEvent("tool", name, m.Content, "", ag.NextBlockID()))
} }
} }
} }

View File

@ -1,15 +1,20 @@
package format package format
import ( import (
"fmt"
"strings" "strings"
) )
// FormatEvent converts event data to bytes for appending to a chat log. // FormatEvent converts event data to bytes for appending to a chat log.
// Streaming roles (assistant, reasoning, retry) return only the content chunk; // Streaming roles (assistant, reasoning, retry) return only the content chunk;
// the caller is responsible for writing the [[[role]]] header for those. // the caller is responsible for writing the [[[role]]] header for those.
func FormatEvent(role, name, content, outputFormat string) []byte { // If blockID is non-empty, it is included in the block header.
func FormatEvent(role, name, content, outputFormat, blockID string) []byte {
switch role { switch role {
case "user": case "user":
if blockID != "" {
return []byte(fmt.Sprintf("[[[user#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
}
return []byte(UserTag + "\n" + content + "\n" + EndTag + "\n") return []byte(UserTag + "\n" + content + "\n" + EndTag + "\n")
case "assistant": case "assistant":
return []byte(content) return []byte(content)
@ -17,26 +22,44 @@ func FormatEvent(role, name, content, outputFormat string) []byte {
return []byte(content) return []byte(content)
case "call": case "call":
args := strings.Join(strings.Fields(content), " ") args := strings.Join(strings.Fields(content), " ")
if blockID != "" {
return []byte(fmt.Sprintf("[[[call:%s#%s]]]\n", name, blockID) + "```\n" + args + "\n```\n" + EndTag + "\n")
}
return []byte(CallDelim(name) + "```\n" + args + "\n```\n" + EndTag + "\n") return []byte(CallDelim(name) + "```\n" + args + "\n```\n" + EndTag + "\n")
case "tool": case "tool":
fence := "```" fence := "```"
if outputFormat != "" { if outputFormat != "" {
fence += outputFormat fence += outputFormat
} }
if blockID != "" {
return []byte(fmt.Sprintf("[[[tool:%s#%s]]]\n", name, blockID) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n")
}
return []byte(ToolDelim(name) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n") return []byte(ToolDelim(name) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n")
case "error": case "error":
role := "error" r := "error"
if name != "" { if name != "" {
role = "error:" + name r = "error:" + name
} }
return []byte(RoleDelim(role) + "\n" + content + "\n" + EndTag + "\n") if blockID != "" {
return []byte(fmt.Sprintf("[[[%s#%s]]]\n", r, blockID) + content + "\n" + EndTag + "\n")
}
return []byte(RoleDelim(r) + "\n" + content + "\n" + EndTag + "\n")
case "info": case "info":
if blockID != "" {
return []byte(fmt.Sprintf("[[[info#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
}
return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n") return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n")
case "retry": case "retry":
return []byte(content) return []byte(content)
case "stalled": case "stalled":
if blockID != "" {
return []byte(fmt.Sprintf("[[[stalled#%s]]]\n", blockID) + EndTag + "\n")
}
return []byte(RoleDelim("stalled") + "\n" + EndTag + "\n") return []byte(RoleDelim("stalled") + "\n" + EndTag + "\n")
case "maxsteps": case "maxsteps":
if blockID != "" {
return []byte(fmt.Sprintf("[[[maxsteps#%s]]]\n", blockID) + content + "\n" + EndTag + "\n")
}
return []byte(RoleDelim("maxsteps") + "\n" + content + "\n" + EndTag + "\n") return []byte(RoleDelim("maxsteps") + "\n" + content + "\n" + EndTag + "\n")
default: default:
return nil return nil

View File

@ -7,6 +7,9 @@ import (
// Formatting constants for the 9P chat log protocol. // Formatting constants for the 9P chat log protocol.
// Used by fs.NewEventHandler and fs.replayMessagesToLog. // Used by fs.NewEventHandler and fs.replayMessagesToLog.
//
// Block format: [[[role:name#blockId]]] or [[[role#blockId]]]
// The blockId is a hex string that uniquely identifies the block.
const ( const (
// EndTag marks the end of a message block. // EndTag marks the end of a message block.
@ -55,13 +58,23 @@ func CallDelim(name string) string {
return fmt.Sprintf(CallDelimPrefix+"%s]]]\n", name) return fmt.Sprintf(CallDelimPrefix+"%s]]]\n", name)
} }
// BlockDelim returns a formatted block delimiter with a block ID.
// Format: [[[role#blockId]]] or [[[role:name#blockId]]]
func BlockDelim(role, name, blockID string) string {
if name != "" {
return fmt.Sprintf("[[[%s:%s#%s]]]\n", role, name, blockID)
}
return fmt.Sprintf("[[[%s#%s]]]\n", role, blockID)
}
// BlockHeader is a single-line [[[...]]] marker that opens a log block. // BlockHeader is a single-line [[[...]]] marker that opens a log block.
type BlockHeader struct { type BlockHeader struct {
Role string // e.g. "user", "assistant", "tool", "retry", or "tool:shell" Role string // e.g. "user", "assistant", "tool", "retry", or "tool:shell"
Name string // for tool/call: the sub-name (e.g. "shell"), empty for bare roles Name string // for tool/call: the sub-name (e.g. "shell"), empty for bare roles
BlockID string // unique block identifier (hex), empty if not present
} }
// ParseBlockHeader extracts a BlockHeader from a line like "[[[tool:shell]]]" // ParseBlockHeader extracts a BlockHeader from a line like "[[[tool:shell#a1b2]]]"
// or "[[[assistant]]]". Returns nil if the line is not a valid block header. // or "[[[assistant]]]". Returns nil if the line is not a valid block header.
func ParseBlockHeader(line string) *BlockHeader { func ParseBlockHeader(line string) *BlockHeader {
const prefix, suffix = "[[[", "]]]" const prefix, suffix = "[[[", "]]]"
@ -74,6 +87,13 @@ func ParseBlockHeader(line string) *BlockHeader {
return nil return nil
} }
bh := &BlockHeader{Role: inner} bh := &BlockHeader{Role: inner}
// Extract blockId if present (after #)
if idx := strings.LastIndex(inner, "#"); idx >= 0 {
bh.BlockID = inner[idx+1:]
inner = inner[:idx]
bh.Role = inner
}
// Extract name if present (after :)
if idx := strings.Index(inner, ":"); idx >= 0 { if idx := strings.Index(inner, ":"); idx >= 0 {
bh.Role = inner[:idx] bh.Role = inner[:idx]
bh.Name = inner[idx+1:] bh.Name = inner[idx+1:]

View File

@ -48,13 +48,14 @@ Item {
chatView.positionViewAtIndex(idx, ListView.Center) chatView.positionViewAtIndex(idx, ListView.Center)
return true return true
} }
// Block not in current view — reload full chat and try again // Block not in current view — it may have been trimmed.
// Server-side block IDs are deterministic, so reload full chat.
if (ollie.activeSessionId && ollie.activeAgentId) { if (ollie.activeSessionId && ollie.activeAgentId) {
chatModel.reset() chatModel.reset()
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
if (log) { if (log) {
chatModel.appendData(log) chatModel.appendData(log)
// Don't trim — keep full history so we can find the block // Don't trim — the bookmark target may be in older history
idx = chatModel.indexForBlockId(blockId) idx = chatModel.indexForBlockId(blockId)
if (idx >= 0) { if (idx >= 0) {
chatView.positionViewAtIndex(idx, ListView.Center) chatView.positionViewAtIndex(idx, ListView.Center)

View File

@ -813,19 +813,27 @@ void ChatBlockModel::processLine(const QString &line)
{ {
switch (m_state) { switch (m_state) {
case State::BlockStart: { case State::BlockStart: {
// [[[blockname]]] starts a new block // [[[blockname]]] or [[[blockname#blockId]]] starts a new block
QRegularExpressionMatch match = s_headerRe.match(line); QRegularExpressionMatch match = s_headerRe.match(line);
if (match.hasMatch()) { if (match.hasMatch()) {
const QString blockName = match.captured(1); QString blockName = match.captured(1);
beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size());
ChatBlock newBlock; ChatBlock newBlock;
newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
newBlock.name = blockName.section(':', 1); // Extract blockId if present (after #)
// Generate stable block ID: sequential counter + type + name hash int hashIdx = blockName.lastIndexOf('#');
// The counter ensures uniqueness even for identical type/name pairs if (hashIdx >= 0) {
newBlock.blockId = blockName.mid(hashIdx + 1);
blockName = blockName.left(hashIdx);
} else {
// Fallback: generate client-side ID for legacy blocks without server IDs
QString idSource = QString::number(m_blockIdCounter++) + blockName; QString idSource = QString::number(m_blockIdCounter++) + blockName;
newBlock.blockId = QString::fromLatin1( newBlock.blockId = QString::fromLatin1(
QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12)); QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12));
}
newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
newBlock.name = blockName.section(':', 1);
m_blocks.append(newBlock); m_blocks.append(newBlock);
endInsertRows(); endInsertRows();
emit countChanged(); emit countChanged();