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:
parent
233a5cc2e9
commit
46aa901763
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
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]]]"
|
||||
// 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:]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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
|
||||
|
||||
// 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);
|
||||
m_blocks.append(newBlock);
|
||||
endInsertRows();
|
||||
emit countChanged();
|
||||
|
|
|
|||
Loading…
Reference in New Issue