Ctrl K
ComunidadGuías…

pluginapi/prompt.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package pluginapi

import (
	"encoding/json"
	"errors"
	"fmt"
	"slices"
	"strings"
)

// Tool is a tool definition sent with the request. Read-only in this version.
type Tool struct {
	Name        string
	Description string
	// Parameters is the JSON schema of the tool's arguments.
	Parameters json.RawMessage
	// Raw is the tool definition as sent.
	Raw json.RawMessage
}

// Params are the request parameters GoModel models. Edit them through
// [Prompt.SetParam]; direct assignment is not applied.
type Params struct {
	// Model is the model the request is addressed to after routing.
	Model string
	// MaxTokens is the completion length cap (chat max_tokens, Responses
	// max_output_tokens); nil when unset.
	MaxTokens   *int
	Temperature *float64
	TopP        *float64
	// Stream reports a streaming request.
	Stream bool
	// ToolChoice is the tool_choice value as sent (string or object).
	ToolChoice any
	// Extra is a read-only view of the other body-level fields, keyed by
	// their JSON name.
	Extra map[string]any
}

// Prompt is the unified request: the conversation, tools, and parameters.
// Read it freely; edit it only through its methods so the host can re-encode
// exactly what changed.
type Prompt struct {
	// Messages is the conversation in order, including system messages.
	Messages []Message
	// Tools are the tool definitions sent with the request.
	Tools []Tool
	// Params are the modelled request parameters.
	Params Params
	// Raw is the request body as received. Read-only.
	Raw json.RawMessage

	changes Changes
	removed []Message
	nextID  int
}

// ToolCallRef locates a tool call in the conversation.
type ToolCallRef struct {
	// MessageID is the message holding the call.
	MessageID string
	Call      ToolCall
	// HasResult reports whether a tool result for the call is present.
	HasResult bool
}

// DanglingToolError is returned by [Prompt.Remove] when the removal leaves a
// tool call without its result or a result without its call. The removal is
// applied; remove PartnerID as well to make the conversation consistent
// again. The host rejects a prompt that still has dangling pairs.
type DanglingToolError struct {
	// MessageID is the removed message.
	MessageID string
	// PartnerID is the message that now lacks its call or result.
	PartnerID string
	// CallID is the tool call whose pairing broke.
	CallID string
}

func (e *DanglingToolError) Error() string {
	return fmt.Sprintf("pluginapi: removing message %q leaves tool call %q dangling in message %q; remove it too", e.MessageID, e.CallID, e.PartnerID)
}

// LastUser returns the most recent user message, or nil.
func (p *Prompt) LastUser() *Message {
	for i := len(p.Messages) - 1; i >= 0; i-- {
		if p.Messages[i].Role == RoleUser {
			return &p.Messages[i]
		}
	}
	return nil
}

// Text returns the text parts of every message with one of the given roles
// (all roles when none is given), joined by newlines.
func (p *Prompt) Text(roles ...Role) string {
	var texts []string
	for _, m := range p.Messages {
		if len(roles) > 0 && !containsRole(roles, m.Role) {
			continue
		}
		for _, part := range m.Parts {
			if part.Kind == PartText {
				texts = append(texts, part.Text)
			}
		}
	}
	return strings.Join(texts, "\n")
}

// SystemText returns the text of system and developer messages.
func (p *Prompt) SystemText() string {
	return p.Text(RoleSystem, RoleDeveloper)
}

// ToolCalls lists every tool call in the conversation with the message that
// holds it and whether a matching result exists.
func (p *Prompt) ToolCalls() []ToolCallRef {
	results := map[string]bool{}
	for _, m := range p.Messages {
		for _, part := range m.Parts {
			if part.Kind == PartToolResult && part.ToolResult != nil {
				results[part.ToolResult.CallID] = true
			}
		}
	}
	var refs []ToolCallRef
	for _, m := range p.Messages {
		for _, part := range m.Parts {
			if part.Kind == PartToolCall && part.ToolCall != nil {
				refs = append(refs, ToolCallRef{MessageID: m.ID, Call: *part.ToolCall, HasResult: results[part.ToolCall.ID]})
			}
		}
	}
	return refs
}

// NewSince returns the messages after the first n, for plugins that only
// scan new turns.
func (p *Prompt) NewSince(n int) []Message {
	if n < 0 {
		n = 0
	}
	if n >= len(p.Messages) {
		return nil
	}
	return p.Messages[n:]
}

// Message returns the message with the given ID, or nil. The pointer is
// valid until the next Insert, Append, or Remove.
func (p *Prompt) Message(id string) *Message {
	for i := range p.Messages {
		if p.Messages[i].ID == id {
			return &p.Messages[i]
		}
	}
	return nil
}

// SetText replaces the text of part partIdx of message msgID. The part must
// be a text part.
func (p *Prompt) SetText(msgID string, partIdx int, text string) error {
	return p.setPartText(msgID, partIdx, PartText, text)
}

// SetReasoning replaces the text of part partIdx of message msgID. The part
// must be a reasoning part: the reasoning a replayed assistant turn carries.
func (p *Prompt) SetReasoning(msgID string, partIdx int, text string) error {
	return p.setPartText(msgID, partIdx, PartReasoning, text)
}

// setPartText replaces the text of part partIdx of message msgID, which
// must be of the given kind.
func (p *Prompt) setPartText(msgID string, partIdx int, kind PartKind, text string) error {
	m := p.Message(msgID)
	if m == nil {
		return unknownMessage(msgID)
	}
	if partIdx < 0 || partIdx >= len(m.Parts) {
		return fmt.Errorf("pluginapi: message %q has no part %d", msgID, partIdx)
	}
	if m.Parts[partIdx].Kind != kind {
		return fmt.Errorf("pluginapi: part %d of message %q is %s, not %s", partIdx, msgID, m.Parts[partIdx].Kind, kind)
	}
	m.Parts[partIdx].Text = text
	p.changes.mark(msgID, ChangeEdited)
	return nil
}

// SetToolArguments replaces the arguments of tool call callID in message
// msgID. args must be valid JSON.
func (p *Prompt) SetToolArguments(msgID, callID string, args json.RawMessage) error {
	m := p.Message(msgID)
	if m == nil {
		return unknownMessage(msgID)
	}
	if !json.Valid(args) {
		return fmt.Errorf("pluginapi: tool arguments for call %q are not valid JSON", callID)
	}
	for i := range m.Parts {
		part := &m.Parts[i]
		if part.Kind == PartToolCall && part.ToolCall != nil && part.ToolCall.ID == callID {
			call := *part.ToolCall
			call.Arguments = append(json.RawMessage(nil), args...)
			part.ToolCall = &call
			p.changes.mark(msgID, ChangeEdited)
			return nil
		}
	}
	return fmt.Errorf("pluginapi: message %q has no tool call %q", msgID, callID)
}

// SetToolResult replaces the content of the result for call callID in
// message msgID.
func (p *Prompt) SetToolResult(msgID, callID string, parts []Part) error {
	m := p.Message(msgID)
	if m == nil {
		return unknownMessage(msgID)
	}
	for i := range m.Parts {
		part := &m.Parts[i]
		if part.Kind == PartToolResult && part.ToolResult != nil && part.ToolResult.CallID == callID {
			result := *part.ToolResult
			result.Parts = append([]Part(nil), parts...)
			part.ToolResult = &result
			p.changes.mark(msgID, ChangeEdited)
			return nil
		}
	}
	return fmt.Errorf("pluginapi: message %q has no tool result for call %q", msgID, callID)
}

// Insert adds m at position at (clamped to the conversation bounds) and
// returns its generated ID. Any ID on m is replaced.
func (p *Prompt) Insert(at int, m Message) string {
	if at < 0 {
		at = 0
	}
	if at > len(p.Messages) {
		at = len(p.Messages)
	}
	m.ID = p.newID()
	p.Messages = append(p.Messages, Message{})
	copy(p.Messages[at+1:], p.Messages[at:])
	p.Messages[at] = m
	p.changes.mark(m.ID, ChangeInserted)
	return m.ID
}

// Append adds m at the end of the conversation and returns its generated ID.
func (p *Prompt) Append(m Message) string {
	return p.Insert(len(p.Messages), m)
}

// Remove drops the message with the given ID. Removing a message a plugin
// inserted just forgets it; removing an original message is recorded for the
// host. When the removal leaves a tool call without its result (or the
// reverse) the message is still removed and a [DanglingToolError] names the
// partner message to remove next.
func (p *Prompt) Remove(msgID string) error {
	idx := -1
	for i := range p.Messages {
		if p.Messages[i].ID == msgID {
			idx = i
			break
		}
	}
	if idx < 0 {
		return unknownMessage(msgID)
	}
	removed := p.Messages[idx]
	p.Messages = append(p.Messages[:idx], p.Messages[idx+1:]...)
	if p.changes.Messages[msgID] == ChangeInserted {
		delete(p.changes.Messages, msgID)
	} else {
		p.changes.mark(msgID, ChangeRemoved)
		p.removed = append(p.removed, removed)
	}
	return p.dangling(removed)
}

// SetParam records a request parameter change: "max_tokens", "temperature",
// "top_p", or any other body-level key. The typed Params fields are updated
// for the three modelled names; the host decides which other keys it can
// apply. "model" and "stream" cannot be changed after routing.
func (p *Prompt) SetParam(name string, value any) {
	switch name {
	case "max_tokens":
		if n, ok := toInt(value); ok {
			p.Params.MaxTokens = &n
		}
	case "temperature":
		if f, ok := toFloat(value); ok {
			p.Params.Temperature = &f
		}
	case "top_p":
		if f, ok := toFloat(value); ok {
			p.Params.TopP = &f
		}
	}
	p.changes.setParam(name, value)
}

// Changes reports what was edited. Host-facing; plugins do not need it.
func (p *Prompt) Changes() Changes {
	return p.changes.clone()
}

// Clone returns an independent copy of the prompt and its change tracking:
// later edits on either side do not reach the other, and the host can apply
// the copy's edits on its own (see Changes). Raw and Tools are shared, as
// nothing edits them. Host-facing; plugins do not need it.
func (p *Prompt) Clone() *Prompt {
	if p == nil {
		return nil
	}
	params := p.Params
	params.MaxTokens = cloneInt(p.Params.MaxTokens)
	params.Temperature = cloneFloat(p.Params.Temperature)
	params.TopP = cloneFloat(p.Params.TopP)
	params.ToolChoice = cloneJSONValue(p.Params.ToolChoice)
	if extra, ok := cloneJSONValue(p.Params.Extra).(map[string]any); ok {
		params.Extra = extra
	}
	return &Prompt{
		Messages: cloneMessages(p.Messages),
		Tools:    p.Tools,
		Params:   params,
		Raw:      p.Raw,
		changes:  p.changes.clone(),
		removed:  cloneMessages(p.removed),
		nextID:   p.nextID,
	}
}

func cloneMessages(messages []Message) []Message {
	if messages == nil {
		return nil
	}
	out := make([]Message, len(messages))
	for i, m := range messages {
		m.Parts = cloneParts(m.Parts)
		out[i] = m
	}
	return out
}

// cloneParts copies the parts and the tool call/result each may point to,
// tool-call arguments included; media bytes and raw JSON are shared, as no
// edit rewrites them in place.
func cloneParts(parts []Part) []Part {
	if parts == nil {
		return nil
	}
	out := make([]Part, len(parts))
	for i, part := range parts {
		if part.ToolCall != nil {
			call := *part.ToolCall
			if call.Arguments != nil {
				call.Arguments = append(json.RawMessage(nil), call.Arguments...)
			}
			part.ToolCall = &call
		}
		if part.ToolResult != nil {
			result := *part.ToolResult
			result.Parts = cloneParts(result.Parts)
			part.ToolResult = &result
		}
		out[i] = part
	}
	return out
}

// cloneJSONValue deep-copies a decoded JSON value (objects and arrays);
// scalars are returned as they are.
func cloneJSONValue(v any) any {
	switch value := v.(type) {
	case map[string]any:
		out := make(map[string]any, len(value))
		for k, item := range value {
			out[k] = cloneJSONValue(item)
		}
		return out
	case []any:
		out := make([]any, len(value))
		for i, item := range value {
			out[i] = cloneJSONValue(item)
		}
		return out
	default:
		return v
	}
}

func cloneInt(v *int) *int {
	if v == nil {
		return nil
	}
	c := *v
	return &c
}

func cloneFloat(v *float64) *float64 {
	if v == nil {
		return nil
	}
	c := *v
	return &c
}

// Reset clears change tracking after the host has built the prompt or
// applied the edits. Host-facing; plugin authors never call it.
func (p *Prompt) Reset() {
	p.changes = Changes{}
	p.removed = nil
}

// Validate reports the first tool call/result pair a removal broke, as a
// [DanglingToolError]. Host-facing: the host calls it before applying edits.
func (p *Prompt) Validate() error {
	for _, m := range p.removed {
		if err := p.dangling(m); err != nil {
			return err
		}
	}
	return nil
}

func (p *Prompt) dangling(removed Message) error {
	for _, part := range removed.Parts {
		switch {
		case part.Kind == PartToolCall && part.ToolCall != nil:
			if partner := p.findToolPart(PartToolResult, part.ToolCall.ID); partner != "" {
				return &DanglingToolError{MessageID: removed.ID, PartnerID: partner, CallID: part.ToolCall.ID}
			}
		case part.Kind == PartToolResult && part.ToolResult != nil:
			if partner := p.findToolPart(PartToolCall, part.ToolResult.CallID); partner != "" {
				return &DanglingToolError{MessageID: removed.ID, PartnerID: partner, CallID: part.ToolResult.CallID}
			}
		}
	}
	return nil
}

func (p *Prompt) findToolPart(kind PartKind, callID string) string {
	for _, m := range p.Messages {
		for _, part := range m.Parts {
			if part.Kind != kind {
				continue
			}
			if kind == PartToolCall && part.ToolCall != nil && part.ToolCall.ID == callID {
				return m.ID
			}
			if kind == PartToolResult && part.ToolResult != nil && part.ToolResult.CallID == callID {
				return m.ID
			}
		}
	}
	return ""
}

func (p *Prompt) newID() string {
	for {
		p.nextID++
		id := fmt.Sprintf("new-%d", p.nextID)
		if p.Message(id) == nil && p.changes.Messages[id] == "" {
			return id
		}
	}
}

func unknownMessage(id string) error {
	return fmt.Errorf("pluginapi: %w: %q", errUnknownMessage, id)
}

var errUnknownMessage = errors.New("unknown message")

func containsRole(roles []Role, role Role) bool {
	return slices.Contains(roles, role)
}

func toInt(v any) (int, bool) {
	switch n := v.(type) {
	case int:
		return n, true
	case int32:
		return int(n), true
	case int64:
		return int(n), true
	case float64:
		return int(n), true
	case float32:
		return int(n), true
	case json.Number:
		i, err := n.Int64()
		return int(i), err == nil
	case *int:
		if n == nil {
			return 0, false
		}
		return *n, true
	}
	return 0, false
}

func toFloat(v any) (float64, bool) {
	switch f := v.(type) {
	case float64:
		return f, true
	case float32:
		return float64(f), true
	case int:
		return float64(f), true
	case int64:
		return float64(f), true
	case json.Number:
		x, err := f.Float64()
		return x, err == nil
	case *float64:
		if f == nil {
			return 0, false
		}
		return *f, true
	}
	return 0, false
}