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)
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in New Issue