response IDs and structured reactions with exact targeting
This commit is contained in:
parent
c656846cce
commit
af9a22b1db
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue