diff --git a/cmd/olliesrv/internal/agent/agent.go b/cmd/olliesrv/internal/agent/agent.go index a1a1fe0..b9bed1f 100644 --- a/cmd/olliesrv/internal/agent/agent.go +++ b/cmd/olliesrv/internal/agent/agent.go @@ -76,6 +76,7 @@ type Agent struct { chatSignalMu sync.Mutex chatSignalCh chan struct{} // closed on new chat data; replaced with fresh channel plan []byte // agent's plan file contents + blockCounter uint64 // monotonic counter for generating stable block IDs } // Backend returns the active backend from the runtime. diff --git a/cmd/olliesrv/internal/agent/chat.go b/cmd/olliesrv/internal/agent/chat.go index 2aacb33..d0e9d3c 100644 --- a/cmd/olliesrv/internal/agent/chat.go +++ b/cmd/olliesrv/internal/agent/chat.go @@ -9,12 +9,20 @@ package agent import ( "fmt" "sync" + "sync/atomic" ) // --- Chat log methods --- 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. func (ag *Agent) AppendChat(data []byte) { if len(data) == 0 { diff --git a/cmd/olliesrv/internal/agent/chatlog.go b/cmd/olliesrv/internal/agent/chatlog.go index 2db17d2..8bf3bee 100644 --- a/cmd/olliesrv/internal/agent/chatlog.go +++ b/cmd/olliesrv/internal/agent/chatlog.go @@ -7,6 +7,8 @@ package agent import ( + "fmt" + "ollie/cmd/olliesrv/internal/backend" "ollie/format" ) @@ -17,6 +19,7 @@ func (ag *Agent) initChatHandler() { var streamingRole string var streamingResponseID string var streamingFence string + var streamingBlockID string ag.output = func(ev Event) { switch ev.Role { @@ -29,15 +32,18 @@ func (ag *Agent) initChatHandler() { } ag.AppendChat([]byte("\n" + format.EndTag + "\n")) } + blockID := ag.NextBlockID() 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 } else { - ag.AppendChat([]byte(format.RoleDelim(ev.Role) + "\n")) + ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID))) } 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": if streamingRole != ev.Role { @@ -48,10 +54,12 @@ func (ag *Agent) initChatHandler() { } 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 + 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": if streamingRole == "tool" { @@ -76,11 +84,15 @@ func (ag *Agent) initChatHandler() { streamingRole = "" } 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 streamingRole = "tool" + streamingBlockID = blockID } 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": @@ -92,9 +104,11 @@ func (ag *Agent) initChatHandler() { ag.AppendChat([]byte("\n" + format.EndTag + "\n")) streamingRole = "" } - ag.AppendChat([]byte(format.RetryTag + "\n")) + blockID := ag.NextBlockID() + ag.AppendChat([]byte(fmt.Sprintf("[[[retry#%s]]]\n", blockID))) 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": if streamingRole != "" { @@ -105,7 +119,7 @@ func (ag *Agent) initChatHandler() { ag.AppendChat([]byte("\n" + format.EndTag + "\n")) 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: if streamingRole != "" { @@ -116,8 +130,9 @@ func (ag *Agent) initChatHandler() { ag.AppendChat([]byte("\n" + format.EndTag + "\n")) 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 { case "system": case "user": - ag.AppendChat(format.FormatEvent("user", "", m.Content, "")) + ag.AppendChat(format.FormatEvent("user", "", m.Content, "", ag.NextBlockID())) case "assistant": 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 != "" { - ag.AppendChat([]byte(format.AssistantHeaderPrefix + m.ID + "]]]\n")) + ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", m.ID, blockID))) } else { - ag.AppendChat([]byte(format.AssistantTag + "\n")) + ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID))) } if m.Content != "" { ag.AppendChat([]byte(m.Content + "\n")) } ag.AppendChat([]byte(format.EndTag + "\n")) 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": name := callNames[m.ToolCallID] if name == "" { name = m.ToolCallID } - ag.AppendChat(format.FormatEvent("tool", name, m.Content, "")) + ag.AppendChat(format.FormatEvent("tool", name, m.Content, "", ag.NextBlockID())) } } } diff --git a/format/event.go b/format/event.go index fee2abc..9200c5e 100644 --- a/format/event.go +++ b/format/event.go @@ -1,15 +1,20 @@ package format import ( + "fmt" "strings" ) // FormatEvent converts event data to bytes for appending to a chat log. // Streaming roles (assistant, reasoning, retry) return only the content chunk; // 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 { case "user": + if blockID != "" { + return []byte(fmt.Sprintf("[[[user#%s]]]\n", blockID) + content + "\n" + EndTag + "\n") + } return []byte(UserTag + "\n" + content + "\n" + EndTag + "\n") case "assistant": return []byte(content) @@ -17,26 +22,44 @@ func FormatEvent(role, name, content, outputFormat string) []byte { return []byte(content) case "call": 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") case "tool": fence := "```" if 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") case "error": - role := "error" + r := "error" 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": + if blockID != "" { + return []byte(fmt.Sprintf("[[[info#%s]]]\n", blockID) + content + "\n" + EndTag + "\n") + } return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n") case "retry": return []byte(content) case "stalled": + if blockID != "" { + return []byte(fmt.Sprintf("[[[stalled#%s]]]\n", blockID) + EndTag + "\n") + } return []byte(RoleDelim("stalled") + "\n" + EndTag + "\n") 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") default: return nil diff --git a/format/format.go b/format/format.go index b3b835d..38385d4 100644 --- a/format/format.go +++ b/format/format.go @@ -7,6 +7,9 @@ import ( // Formatting constants for the 9P chat log protocol. // 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 ( // EndTag marks the end of a message block. @@ -55,13 +58,23 @@ func CallDelim(name string) string { return fmt.Sprintf(CallDelimPrefix+"%s]]]\n", name) } -// BlockHeader is a single-line [[[...]]] marker that opens a log block. -type BlockHeader struct { - 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 +// 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) } -// ParseBlockHeader extracts a BlockHeader from a line like "[[[tool:shell]]]" +// BlockHeader is a single-line [[[...]]] marker that opens a log block. +type BlockHeader struct { + 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 + BlockID string // unique block identifier (hex), empty if not present +} + +// ParseBlockHeader extracts a BlockHeader from a line like "[[[tool:shell#a1b2]]]" // or "[[[assistant]]]". Returns nil if the line is not a valid block header. func ParseBlockHeader(line string) *BlockHeader { const prefix, suffix = "[[[", "]]]" @@ -74,6 +87,13 @@ func ParseBlockHeader(line string) *BlockHeader { return nil } 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 { bh.Role = inner[:idx] bh.Name = inner[idx+1:] diff --git a/kde/gui/ChatPane.qml b/kde/gui/ChatPane.qml index 37503da..edca030 100644 --- a/kde/gui/ChatPane.qml +++ b/kde/gui/ChatPane.qml @@ -48,13 +48,14 @@ Item { chatView.positionViewAtIndex(idx, ListView.Center) 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) { chatModel.reset() var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId) if (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) if (idx >= 0) { chatView.positionViewAtIndex(idx, ListView.Center) diff --git a/kde/gui/chatblockmodel.cpp b/kde/gui/chatblockmodel.cpp index 2b910cd..499b346 100644 --- a/kde/gui/chatblockmodel.cpp +++ b/kde/gui/chatblockmodel.cpp @@ -813,19 +813,27 @@ void ChatBlockModel::processLine(const QString &line) { switch (m_state) { case State::BlockStart: { - // [[[blockname]]] starts a new block + // [[[blockname]]] or [[[blockname#blockId]]] starts a new block QRegularExpressionMatch match = s_headerRe.match(line); if (match.hasMatch()) { - const QString blockName = match.captured(1); + QString blockName = match.captured(1); beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); ChatBlock newBlock; + + // Extract blockId if present (after #) + int hashIdx = blockName.lastIndexOf('#'); + 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; + newBlock.blockId = QString::fromLatin1( + QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12)); + } + newBlock.type = blockTypeFromString(blockName.section(':', 0, 0)); newBlock.name = blockName.section(':', 1); - // Generate stable block ID: sequential counter + type + name hash - // The counter ensures uniqueness even for identical type/name pairs - QString idSource = QString::number(m_blockIdCounter++) + blockName; - newBlock.blockId = QString::fromLatin1( - QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12)); m_blocks.append(newBlock); endInsertRows(); emit countChanged();