From af9a22b1db3c4291eecf533e48e9c8aff8f9a496 Mon Sep 17 00:00:00 2001 From: ollie Date: Mon, 27 Jul 2026 19:12:12 +0200 Subject: [PATCH] response IDs and structured reactions with exact targeting --- pkg/agent/core.go | 76 ++++++++++++++++++++++++++++++++++-------- pkg/agent/core_test.go | 47 ++++++++++++++++++++++++++ pkg/agent/loop.go | 50 ++++++++++++++------------- pkg/agent/session.go | 48 ++++++++++++++++++++++++-- pkg/agent/types.go | 12 +++---- pkg/backend/backend.go | 13 ++++---- 6 files changed, 194 insertions(+), 52 deletions(-) diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 914476b..64695f3 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -236,6 +236,20 @@ func NewSessionID() string { 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. func infoEvent(text string) Event { 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) { + _ = s.ReactTo("", emoji) +} + +func (s *agent) ReactTo(responseID, emoji string) error { if s.session == nil { - return + return fmt.Errorf("no active session") } - category, desc, positive := classifyReaction(emoji) - var msg string - if desc != "" { - msg = "[reaction: " + category + " (" + emoji + ")] " + desc - } else { - msg = "[reacted " + emoji + " to your previous response]" + category, desc, _ := classifyReaction(emoji) + if category == "unknown" { + return fmt.Errorf("unsupported reaction: %s", emoji) } - s.session.appendUserMessage(msg) - if positive { - s.session.PositiveReactions++ - } else if category == "negative" || category == "terrible" { - s.session.NegativeReactions++ + if responseID == "" { + for i := len(s.session.messages) - 1; i >= 0; i-- { + if s.session.messages[i].Role == "assistant" { + responseID = s.session.messages[i].ID + 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 { diff --git a/pkg/agent/core_test.go b/pkg/agent/core_test.go index e31d3e2..2ff5d71 100644 --- a/pkg/agent/core_test.go +++ b/pkg/agent/core_test.go @@ -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) { msgs := []backend.Message{ {Role: "assistant", Content: "preamble"}, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 74e0567..db6815b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -44,20 +44,20 @@ const planReinjectInterval = 10 type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) type agentConfig struct { - Backend backend.Backend - Tools []backend.Tool - Exec toolExecutor - 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 - Output EventHandler - preamble string // compiled system+agent prompt sent as the system role - GenerationParams backend.GenerationParams - PopInject func() string // returns and clears pending inject, or "" - 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 - 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 - TurnError func(ctx context.Context, errType, errMsg string) HookResult // called on first backend error; if ran, skips retries + Backend backend.Backend + Tools []backend.Tool + Exec toolExecutor + 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 + Output EventHandler + preamble string // compiled system+agent prompt sent as the system role + GenerationParams backend.GenerationParams + PopInject func() string // returns and clears pending inject, or "" + 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 + 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 + 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. // When reached, a soft nudge is injected and the loop exits cleanly. // 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 - // 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 reasoning strings.Builder var toolCalls []backend.ToolCall @@ -164,7 +166,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { hadReasoning = false } 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...) if ev.Done { @@ -206,7 +208,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { if hadReasoning { emit(cfg, Event{Role: "reasoning", Content: "\n\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 for _, tc := range toolCalls { 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. - 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)) 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}) }) - out, blocks, err := cfg.Exec(streamCtx, tc.Name, tc.Arguments) + out, blocks, err := cfg.Exec(streamCtx, tc.Name, tc.Arguments) if err != nil { isErr = true 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. emit(cfg, Event{Role: "tool", Name: tc.Name, Content: suffix}) } - tier := TierHot - if !isErr && cfg.ClassifyTier != nil { - tier = cfg.ClassifyTier(tc.Name, tc.Arguments) - } + tier := TierHot + if !isErr && cfg.ClassifyTier != nil { + tier = cfg.ClassifyTier(tc.Name, tc.Arguments) + } 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 } - func emit(cfg agentConfig, msg Event) { if cfg.Output != nil { cfg.Output(msg) @@ -707,6 +708,7 @@ func inferTaskStateUpdate(ts *TaskState, msg backend.Message, results []toolResu // Recognizes formats: // - tool_name: {"key": ...} (JSON object) // - tool_name: key=[...], key2=... (shorthand: key=value pairs → JSON object) +// // Returns nil if no valid tool calls are found. func parseTextToolCalls(text string, tools []backend.Tool) []backend.ToolCall { toolNames := make(map[string]bool, len(tools)) diff --git a/pkg/agent/session.go b/pkg/agent/session.go index eee8eb1..a6ca72d 100644 --- a/pkg/agent/session.go +++ b/pkg/agent/session.go @@ -7,6 +7,7 @@ import ( "os" "slices" "strings" + "time" "ollie/pkg/backend" ) @@ -33,6 +34,14 @@ Be concise. Capture what another LLM needs to seamlessly continue.` 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. type PersistedSession struct { ID string `json:"id"` @@ -53,8 +62,9 @@ type PersistedSession struct { LastTurnCostUSD float64 `json:"lastTurnCostUSD,omitempty"` SessionCostUSD float64 `json:"sessionCostUSD,omitempty"` // Reaction tracking. - PositiveReactions int `json:"positiveReactions,omitempty"` - NegativeReactions int `json:"negativeReactions,omitempty"` + PositiveReactions int `json:"positiveReactions,omitempty"` + NegativeReactions int `json:"negativeReactions,omitempty"` + Reactions []Reaction `json:"reactions,omitempty"` } // 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, PositiveReactions: s.PositiveReactions, NegativeReactions: s.NegativeReactions, + Reactions: s.Reactions, } data, err := json.Marshal(ps) if err != nil { @@ -158,6 +169,12 @@ func RestoreSession(ps *PersistedSession) *Session { SessionCostUSD: ps.SessionCostUSD, PositiveReactions: ps.PositiveReactions, 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 { if m.Role == "user" { @@ -191,6 +208,7 @@ type Session struct { // Reaction tracking. PositiveReactions int NegativeReactions int + Reactions []Reaction } // 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 { - 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 { @@ -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() { s.turnInputTokens = 0 s.turnCachedTokens = 0 diff --git a/pkg/agent/types.go b/pkg/agent/types.go index d6e2367..29bb33d 100644 --- a/pkg/agent/types.go +++ b/pkg/agent/types.go @@ -23,9 +23,10 @@ var ErrInterrupted = errors.New("interrupted") // Event is a typed output event emitted during an agent turn or in response // to a command. type Event struct { - Role string - Name string - Content string + Role string + Name string + Content string + ResponseID string } // EventHandler receives events from the agent. @@ -169,10 +170,9 @@ type Core interface { InjectSystemEvent(content string) // 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) + // ReactTo records an emoji reaction to a specific assistant response. + ReactTo(responseID, emoji string) error } // DetachedInfo describes a detached process for external consumers. diff --git a/pkg/backend/backend.go b/pkg/backend/backend.go index 4f97028..48dd5f1 100644 --- a/pkg/backend/backend.go +++ b/pkg/backend/backend.go @@ -16,9 +16,9 @@ import ( // ContentBlock is a single block within a multi-modal message. // When a Message has ContentBlocks set, it takes precedence over Content. type ContentBlock struct { - Type string `json:"type"` // "text" | "image" - Text string `json:"text,omitempty"` // for text blocks - ImageSource *ImageSource `json:"source,omitempty"` // for image blocks + Type string `json:"type"` // "text" | "image" + Text string `json:"text,omitempty"` // for text blocks + ImageSource *ImageSource `json:"source,omitempty"` // for image blocks } // ImageSource describes a base64-encoded image. @@ -30,7 +30,8 @@ type ImageSource struct { // Message is a single conversation turn. 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"` Reasoning string `json:"reasoning,omitempty"` // thinking/reasoning content (DeepSeek, OpenAI o-series) 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. func isContextOverflow(body string) bool { markers := []string{ - "context_length_exceeded", // OpenAI/OpenRouter error code - "prompt is too long", // Anthropic + "context_length_exceeded", // OpenAI/OpenRouter error code + "prompt is too long", // Anthropic "Please reduce the length", // OpenAI prose "too many tokens", }