response IDs and structured reactions with exact targeting

This commit is contained in:
ollie 2026-07-27 19:12:12 +02:00
parent c656846cce
commit af9a22b1db
6 changed files with 194 additions and 52 deletions

View File

@ -236,6 +236,20 @@ func NewSessionID() string {
return strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + fmt.Sprintf("%06x", b) return strconv.FormatInt(time.Now().UnixNano(), 10) + "-" + fmt.Sprintf("%06x", b)
} }
// NewResponseID generates a unique identifier for a single assistant response.
func NewResponseID() string {
b := make([]byte, 3)
rand.Read(b) //nolint:errcheck
return "resp_" + strconv.FormatInt(time.Now().UnixNano(), 10) + "_" + fmt.Sprintf("%06x", b)
}
// NewReactionID generates a unique identifier for a user reaction.
func NewReactionID() string {
b := make([]byte, 3)
rand.Read(b) //nolint:errcheck
return "react_" + strconv.FormatInt(time.Now().UnixNano(), 10) + "_" + fmt.Sprintf("%06x", b)
}
// infoEvent wraps a plain-text message as an info Event. // infoEvent wraps a plain-text message as an info Event.
func infoEvent(text string) Event { func infoEvent(text string) Event {
return Event{Role: "info", Content: text + "\n"} return Event{Role: "info", Content: text + "\n"}
@ -626,23 +640,59 @@ func classifyReaction(emoji string) (category, description string, positive bool
} }
func (s *agent) React(emoji string) { func (s *agent) React(emoji string) {
_ = s.ReactTo("", emoji)
}
func (s *agent) ReactTo(responseID, emoji string) error {
if s.session == nil { if s.session == nil {
return return fmt.Errorf("no active session")
} }
category, desc, positive := classifyReaction(emoji) category, desc, _ := classifyReaction(emoji)
var msg string if category == "unknown" {
if desc != "" { return fmt.Errorf("unsupported reaction: %s", emoji)
msg = "[reaction: " + category + " (" + emoji + ")] " + desc
} else {
msg = "[reacted " + emoji + " to your previous response]"
} }
s.session.appendUserMessage(msg) if responseID == "" {
if positive { for i := len(s.session.messages) - 1; i >= 0; i-- {
s.session.PositiveReactions++ if s.session.messages[i].Role == "assistant" {
} else if category == "negative" || category == "terrible" { responseID = s.session.messages[i].ID
s.session.NegativeReactions++ break
}
}
} }
s.emit(Event{Role: "user", Content: msg}) if responseID == "" {
return fmt.Errorf("no assistant response to react to")
}
found := false
for i := range s.session.messages {
if s.session.messages[i].Role == "assistant" && s.session.messages[i].ID == responseID {
found = true
break
}
}
if !found {
return fmt.Errorf("assistant response not found: %s", responseID)
}
reaction := Reaction{ID: NewReactionID(), ResponseID: responseID, Emoji: emoji, Category: category, CreatedAt: time.Now()}
replaced := false
for i := range s.session.Reactions {
if s.session.Reactions[i].ResponseID == responseID {
if s.session.Reactions[i].Emoji == emoji {
return nil
}
s.session.Reactions[i] = reaction
replaced = true
break
}
}
if !replaced {
s.session.Reactions = append(s.session.Reactions, reaction)
}
s.session.recomputeReactionCounts()
msg := "[reaction to assistant response " + responseID + ": " + category + " (" + emoji + ")] " + desc
s.emit(Event{Role: "user", Content: msg, ResponseID: responseID})
s.saveSession()
return nil
} }
func (s *agent) AgentName() string { func (s *agent) AgentName() string {

View File

@ -2186,6 +2186,53 @@ func TestRestoreSession_RoundTrip(t *testing.T) {
} }
} }
func TestReactionTargetsResponseAndReplaces(t *testing.T) {
c := newCore(t, nil, nil)
c.session = &Session{messages: []backend.Message{
{Role: "assistant", ID: "r1", Content: "first"},
{Role: "assistant", ID: "r2", Content: "second"},
}}
if err := c.ReactTo("r1", "👍"); err != nil {
t.Fatalf("ReactTo: %v", err)
}
if len(c.session.Reactions) != 1 || c.session.Reactions[0].ResponseID != "r1" || c.session.PositiveReactions != 1 {
t.Fatalf("reaction state = %+v positive=%d", c.session.Reactions, c.session.PositiveReactions)
}
if err := c.ReactTo("r1", "👎"); err != nil {
t.Fatalf("replace ReactTo: %v", err)
}
if len(c.session.Reactions) != 1 || c.session.PositiveReactions != 0 || c.session.NegativeReactions != 1 {
t.Fatalf("replaced reaction state = %+v positive=%d negative=%d", c.session.Reactions, c.session.PositiveReactions, c.session.NegativeReactions)
}
ctx := c.Context()
if got := ctx[len(ctx)-1].Content; !strings.Contains(got, "response r1: negative") {
t.Fatalf("reaction context = %q", got)
}
if err := c.ReactTo("missing", "👍"); err == nil {
t.Fatal("ReactTo missing response succeeded")
}
}
func TestReactionPersistsAndRestores(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.json")
s := &Session{
messages: []backend.Message{{Role: "assistant", ID: "r1", Content: "answer"}},
Reactions: []Reaction{{ID: "x1", ResponseID: "r1", Emoji: "🚀", Category: "excellent", CreatedAt: time.Now()}},
PositiveReactions: 1,
}
if err := s.saveTo(path, "id", "agent", "/tmp"); err != nil {
t.Fatal(err)
}
ps, err := LoadPersistedSession(path)
if err != nil {
t.Fatal(err)
}
restored := RestoreSession(ps)
if len(restored.Reactions) != 1 || restored.Reactions[0].ResponseID != "r1" || restored.PositiveReactions != 1 {
t.Fatalf("restored = %+v positive=%d", restored.Reactions, restored.PositiveReactions)
}
}
func TestRestoreSession_GoalFromFirstUserMessage(t *testing.T) { func TestRestoreSession_GoalFromFirstUserMessage(t *testing.T) {
msgs := []backend.Message{ msgs := []backend.Message{
{Role: "assistant", Content: "preamble"}, {Role: "assistant", Content: "preamble"},

View File

@ -44,20 +44,20 @@ const planReinjectInterval = 10
type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error)
type agentConfig struct { type agentConfig struct {
Backend backend.Backend Backend backend.Backend
Tools []backend.Tool Tools []backend.Tool
Exec toolExecutor Exec toolExecutor
ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe
ClassifyTier func(name string, args json.RawMessage) ResultTier // nil=TierHot for all; tools self-classify retention ClassifyTier func(name string, args json.RawMessage) ResultTier // nil=TierHot for all; tools self-classify retention
Output EventHandler Output EventHandler
preamble string // compiled system+agent prompt sent as the system role preamble string // compiled system+agent prompt sent as the system role
GenerationParams backend.GenerationParams GenerationParams backend.GenerationParams
PopInject func() string // returns and clears pending inject, or "" PopInject func() string // returns and clears pending inject, or ""
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
SaveSession func() // called after each state.update(); persists mid-turn progress SaveSession func() // called after each state.update(); persists mid-turn progress
PreTool func(ctx context.Context, name string, args json.RawMessage) HookResult // called before each tool; exit 2 blocks execution PreTool func(ctx context.Context, name string, args json.RawMessage) HookResult // called before each tool; exit 2 blocks execution
PostTool func(ctx context.Context, name string, args json.RawMessage, result string) HookResult // called after each tool; exit 0 appends, exit 2 replaces result PostTool func(ctx context.Context, name string, args json.RawMessage, result string) HookResult // called after each tool; exit 0 appends, exit 2 replaces result
TurnError func(ctx context.Context, errType, errMsg string) HookResult // called on first backend error; if ran, skips retries TurnError func(ctx context.Context, errType, errMsg string) HookResult // called on first backend error; if ran, skips retries
// MaxSteps is the maximum number of tool-call rounds per turn. // MaxSteps is the maximum number of tool-call rounds per turn.
// When reached, a soft nudge is injected and the loop exits cleanly. // When reached, a soft nudge is injected and the loop exits cleanly.
// 0 means unlimited. // 0 means unlimited.
@ -108,7 +108,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
} }
// Stream the assistant's response, retrying on rate limits, transient // Stream the assistant's response, retrying on rate limits, transient
// backend errors (5xx, network), and mid-stream drops. // backend errors (5xx, network), and mid-stream drops. One stable ID is
// shared by all chunks and the completed assistant message.
responseID := NewResponseID()
var content strings.Builder var content strings.Builder
var reasoning strings.Builder var reasoning strings.Builder
var toolCalls []backend.ToolCall var toolCalls []backend.ToolCall
@ -164,7 +166,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
hadReasoning = false hadReasoning = false
} }
content.WriteString(ev.Content) content.WriteString(ev.Content)
emit(cfg, Event{Role: "assistant", Content: ev.Content}) emit(cfg, Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
} }
toolCalls = append(toolCalls, ev.ToolCalls...) toolCalls = append(toolCalls, ev.ToolCalls...)
if ev.Done { if ev.Done {
@ -206,7 +208,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
if hadReasoning { if hadReasoning {
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"}) emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
} }
msg := backend.Message{Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls} msg := backend.Message{ID: responseID, Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
var results []toolResult var results []toolResult
for _, tc := range toolCalls { for _, tc := range toolCalls {
results = append(results, toolResult{ results = append(results, toolResult{
@ -253,7 +255,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
} }
// Execute tool calls, running consecutive parallel-read-safe tools concurrently. // Execute tool calls, running consecutive parallel-read-safe tools concurrently.
msg := backend.Message{Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls} msg := backend.Message{ID: responseID, Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
results := make([]toolResult, 0, len(toolCalls)) results := make([]toolResult, 0, len(toolCalls))
interrupted := false interrupted := false
@ -323,7 +325,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
} }
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: data}) emit(cfg, Event{Role: "tool", Name: tc.Name, Content: data})
}) })
out, blocks, err := cfg.Exec(streamCtx, tc.Name, tc.Arguments) out, blocks, err := cfg.Exec(streamCtx, tc.Name, tc.Arguments)
if err != nil { if err != nil {
isErr = true isErr = true
if ctx.Err() != nil { if ctx.Err() != nil {
@ -387,10 +389,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
// so they appear at the end of the chat output. // so they appear at the end of the chat output.
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: suffix}) emit(cfg, Event{Role: "tool", Name: tc.Name, Content: suffix})
} }
tier := TierHot tier := TierHot
if !isErr && cfg.ClassifyTier != nil { if !isErr && cfg.ClassifyTier != nil {
tier = cfg.ClassifyTier(tc.Name, tc.Arguments) tier = cfg.ClassifyTier(tc.Name, tc.Arguments)
} }
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
} }
@ -636,7 +638,6 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
return nil return nil
} }
func emit(cfg agentConfig, msg Event) { func emit(cfg agentConfig, msg Event) {
if cfg.Output != nil { if cfg.Output != nil {
cfg.Output(msg) cfg.Output(msg)
@ -707,6 +708,7 @@ func inferTaskStateUpdate(ts *TaskState, msg backend.Message, results []toolResu
// Recognizes formats: // Recognizes formats:
// - tool_name: {"key": ...} (JSON object) // - tool_name: {"key": ...} (JSON object)
// - tool_name: key=[...], key2=... (shorthand: key=value pairs → JSON object) // - tool_name: key=[...], key2=... (shorthand: key=value pairs → JSON object)
//
// Returns nil if no valid tool calls are found. // Returns nil if no valid tool calls are found.
func parseTextToolCalls(text string, tools []backend.Tool) []backend.ToolCall { func parseTextToolCalls(text string, tools []backend.Tool) []backend.ToolCall {
toolNames := make(map[string]bool, len(tools)) toolNames := make(map[string]bool, len(tools))

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"slices" "slices"
"strings" "strings"
"time"
"ollie/pkg/backend" "ollie/pkg/backend"
) )
@ -33,6 +34,14 @@ Be concise. Capture what another LLM needs to seamlessly continue.`
warmIndexSize = 10 warmIndexSize = 10
) )
type Reaction struct {
ID string `json:"id"`
ResponseID string `json:"responseId"`
Emoji string `json:"emoji"`
Category string `json:"category"`
CreatedAt time.Time `json:"createdAt"`
}
// PersistedSession is the on-disk format for a saved session. // PersistedSession is the on-disk format for a saved session.
type PersistedSession struct { type PersistedSession struct {
ID string `json:"id"` ID string `json:"id"`
@ -53,8 +62,9 @@ type PersistedSession struct {
LastTurnCostUSD float64 `json:"lastTurnCostUSD,omitempty"` LastTurnCostUSD float64 `json:"lastTurnCostUSD,omitempty"`
SessionCostUSD float64 `json:"sessionCostUSD,omitempty"` SessionCostUSD float64 `json:"sessionCostUSD,omitempty"`
// Reaction tracking. // Reaction tracking.
PositiveReactions int `json:"positiveReactions,omitempty"` PositiveReactions int `json:"positiveReactions,omitempty"`
NegativeReactions int `json:"negativeReactions,omitempty"` NegativeReactions int `json:"negativeReactions,omitempty"`
Reactions []Reaction `json:"reactions,omitempty"`
} }
// TaskState is a compact structured overlay that summarizes the agent's // TaskState is a compact structured overlay that summarizes the agent's
@ -122,6 +132,7 @@ func (s *Session) saveToFull(path, id, agentName, backendName, modelName, cwd, r
SessionCostUSD: s.SessionCostUSD, SessionCostUSD: s.SessionCostUSD,
PositiveReactions: s.PositiveReactions, PositiveReactions: s.PositiveReactions,
NegativeReactions: s.NegativeReactions, NegativeReactions: s.NegativeReactions,
Reactions: s.Reactions,
} }
data, err := json.Marshal(ps) data, err := json.Marshal(ps)
if err != nil { if err != nil {
@ -158,6 +169,12 @@ func RestoreSession(ps *PersistedSession) *Session {
SessionCostUSD: ps.SessionCostUSD, SessionCostUSD: ps.SessionCostUSD,
PositiveReactions: ps.PositiveReactions, PositiveReactions: ps.PositiveReactions,
NegativeReactions: ps.NegativeReactions, NegativeReactions: ps.NegativeReactions,
Reactions: ps.Reactions,
}
for i := range s.messages {
if s.messages[i].Role == "assistant" && s.messages[i].ID == "" {
s.messages[i].ID = NewResponseID()
}
} }
for _, m := range ps.Messages { for _, m := range ps.Messages {
if m.Role == "user" { if m.Role == "user" {
@ -191,6 +208,7 @@ type Session struct {
// Reaction tracking. // Reaction tracking.
PositiveReactions int PositiveReactions int
NegativeReactions int NegativeReactions int
Reactions []Reaction
} }
// newSession creates a new empty Session. The caller is responsible for // newSession creates a new empty Session. The caller is responsible for
@ -216,7 +234,18 @@ func (s *Session) Checkpoint(ts TaskState) *Session {
} }
func (s *Session) history() []backend.Message { func (s *Session) history() []backend.Message {
return s.messages if len(s.Reactions) == 0 {
return s.messages
}
// Append feedback after the conversation so assistant tool-call messages stay
// adjacent to their tool results, as required by provider APIs.
out := make([]backend.Message, 0, len(s.messages)+len(s.Reactions))
out = append(out, s.messages...)
for _, r := range s.Reactions {
_, desc, _ := classifyReaction(r.Emoji)
out = append(out, backend.Message{Role: "user", Content: "[reaction to assistant response " + r.ResponseID + ": " + r.Category + " (" + r.Emoji + ")] " + desc})
}
return out
} }
func (s *Session) taskState() *TaskState { func (s *Session) taskState() *TaskState {
@ -243,6 +272,19 @@ func (s *Session) addUsage(u backend.Usage, estimated bool) {
} }
} }
func (s *Session) recomputeReactionCounts() {
s.PositiveReactions = 0
s.NegativeReactions = 0
for _, r := range s.Reactions {
switch r.Category {
case "positive", "excellent":
s.PositiveReactions++
case "negative", "terrible":
s.NegativeReactions++
}
}
}
func (s *Session) resetTurnAccumulators() { func (s *Session) resetTurnAccumulators() {
s.turnInputTokens = 0 s.turnInputTokens = 0
s.turnCachedTokens = 0 s.turnCachedTokens = 0

View File

@ -23,9 +23,10 @@ var ErrInterrupted = errors.New("interrupted")
// Event is a typed output event emitted during an agent turn or in response // Event is a typed output event emitted during an agent turn or in response
// to a command. // to a command.
type Event struct { type Event struct {
Role string Role string
Name string Name string
Content string Content string
ResponseID string
} }
// EventHandler receives events from the agent. // EventHandler receives events from the agent.
@ -169,10 +170,9 @@ type Core interface {
InjectSystemEvent(content string) InjectSystemEvent(content string)
// React records an emoji reaction to the most recent assistant response. // React records an emoji reaction to the most recent assistant response.
// The reaction is appended to session history as a lightweight user message
// and emitted to the chat bus, but does NOT trigger a new agent turn.
// The agent sees it as context on its next turn.
React(emoji string) React(emoji string)
// ReactTo records an emoji reaction to a specific assistant response.
ReactTo(responseID, emoji string) error
} }
// DetachedInfo describes a detached process for external consumers. // DetachedInfo describes a detached process for external consumers.

View File

@ -16,9 +16,9 @@ import (
// ContentBlock is a single block within a multi-modal message. // ContentBlock is a single block within a multi-modal message.
// When a Message has ContentBlocks set, it takes precedence over Content. // When a Message has ContentBlocks set, it takes precedence over Content.
type ContentBlock struct { type ContentBlock struct {
Type string `json:"type"` // "text" | "image" Type string `json:"type"` // "text" | "image"
Text string `json:"text,omitempty"` // for text blocks Text string `json:"text,omitempty"` // for text blocks
ImageSource *ImageSource `json:"source,omitempty"` // for image blocks ImageSource *ImageSource `json:"source,omitempty"` // for image blocks
} }
// ImageSource describes a base64-encoded image. // ImageSource describes a base64-encoded image.
@ -30,7 +30,8 @@ type ImageSource struct {
// Message is a single conversation turn. // Message is a single conversation turn.
type Message struct { type Message struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool" ID string `json:"id,omitempty"` // stable local ID; not sent to providers
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Content string `json:"content"` Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"` // thinking/reasoning content (DeepSeek, OpenAI o-series) Reasoning string `json:"reasoning,omitempty"` // thinking/reasoning content (DeepSeek, OpenAI o-series)
ContentBlocks []ContentBlock `json:"content_blocks,omitempty"` // when set, overrides Content ContentBlocks []ContentBlock `json:"content_blocks,omitempty"` // when set, overrides Content
@ -227,8 +228,8 @@ func isToolUnsupported(body string) bool {
// exceeded the model's context window. // exceeded the model's context window.
func isContextOverflow(body string) bool { func isContextOverflow(body string) bool {
markers := []string{ markers := []string{
"context_length_exceeded", // OpenAI/OpenRouter error code "context_length_exceeded", // OpenAI/OpenRouter error code
"prompt is too long", // Anthropic "prompt is too long", // Anthropic
"Please reduce the length", // OpenAI prose "Please reduce the length", // OpenAI prose
"too many tokens", "too many tokens",
} }