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)
}
// 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 {

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) {
msgs := []backend.Message{
{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 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</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
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))

View File

@ -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

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
// 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.

View File

@ -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",
}