pluginapi/prompt.go
Rama main · réplica de lectura del repositorio original.
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
}