input

package
v0.21.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: AGPL-3.0 Imports: 21 Imported by: 0

Documentation

Overview

Package-shared MCP form-elicitation core: translates an MCP elicitation requestedSchema into the strict ask_user question payload and maps the submitted answers back into schema-conformant content. Runtime adapters (ACP, codex) own only their protocol envelopes around this core.

Index

Constants

View Source
const (
	ToolNameAskUser = "ask_user"

	// Provider sources record which surface created a request, for audit and
	// display only. Nothing classifies on them: whether an answer feeds an
	// in-process waiter or a native continuation is decided by the session's
	// runtime (runtimekind.UsesDecisionWaiter), the same as tool approvals.
	ProviderSourceACPMCP           = "acp_mcp"
	ProviderSourceACPElicitation   = "acp_elicitation"
	ProviderSourceCodexUserInput   = "codex_request_user_input"
	ProviderSourceCodexElicitation = "codex_mcp_elicitation"

	StatusPending   = "pending"
	StatusSubmitted = "submitted"
	StatusCanceled  = "canceled"
	StatusExpired   = "expired"
	StatusFailed    = "failed"

	DeferredKind = "user_input"

	// PayloadVersion is the canonical ask_user payload version written to
	// storage. Older rows are upgraded on read by PayloadFromStored.
	PayloadVersion = 2

	QuestionKindSingleSelect = "single_select"
	QuestionKindMultiSelect  = "multi_select"
	QuestionKindText         = "text"

	MaxQuestionsPerRequest = 4
	MinOptionsPerQuestion  = 2
	MaxOptionsPerQuestion  = 20
)
View Source
const DefaultWaitTimeout = 10 * time.Minute

DefaultWaitTimeout bounds how long ask_user waits for a response before canceling the request.

Variables

View Source
var (
	ErrNotFound       = errors.New("user input request not found")
	ErrAlreadyDecided = errors.New("user input request already decided")
	ErrForbidden      = errors.New("user input forbidden")
)
View Source
var ErrInvalidAskUserInput = errors.New("invalid ask_user input")

Functions

func ApplyInteractionOp

ApplyInteractionOp is the pure transition function for button-driven input. It mirrors the plain-text transitions in advanceTextState where the semantics overlap (advance-on-answer, complete-on-last, skip fill).

func DeferredMetadata

func DeferredMetadata(req Request) map[string]any

func ElicitationURLInput added in v0.20.0

func ElicitationURLInput(message, address string) (any, error)

ElicitationURLInput adapts a browser step to the shared user-input surface. Keep server wording and option identities intact until a surface renders them. Question text is plain on every surface, so the address stands on its own line: the web form links it, channels show it verbatim.

func ResultBytes

func ResultBytes(req Request) []byte

func ValidateAnswers

func ValidateAnswers(payload UIPayload, answers []QuestionAnswer) error

submittedResult validates the user's answers against the stored payload and builds the tool result returned to the model. Every question needs an explicit entry so a deliberate skip cannot be confused with a broken client payload.

func ValidateAskUserInput

func ValidateAskUserInput(input any) error

ValidateAskUserInput reports whether the arguments form a valid ask_user payload. CreatePending still parses again and owns the write-side normalization boundary.

Types

type AdvanceInteractionInput

type AdvanceInteractionInput struct {
	BotID     string
	RequestID string
	Op        InteractionOp
}

type AdvanceInteractionResult

type AdvanceInteractionResult struct {
	// Handled is false when the request no longer exists or is not pending
	// (already answered, canceled, expired) — callers should tell the user
	// the interaction ended rather than surface an error.
	Handled bool
	// Changed reports whether state was persisted; unchanged ops (same-page
	// nav, replay after completion) need no card re-render.
	Changed bool
	Reject  InteractionReject
	Request Request
}

type AdvanceTextInput

type AdvanceTextInput struct {
	UILanguage             string
	BotID                  string
	SessionID              string
	ExplicitID             string
	ReplyExternalMessageID string
	Text                   string
}

type AdvanceTextResult

type AdvanceTextResult struct {
	Handled bool
	Invalid bool
	Request Request
}

type CancelInput

type CancelInput struct {
	RequestID              string
	ActorChannelIdentityID string
	Reason                 string
}

type CreatePendingInput

type CreatePendingInput struct {
	BotID                        string
	SessionID                    string
	RouteID                      string
	ChannelIdentityID            string
	WorkspaceTargetID            string
	RequestedByChannelIdentityID string
	ToolCallID                   string
	ToolName                     string
	Input                        any
	ProviderMetadata             map[string]any
	SourcePlatform               string
	ReplyTarget                  string
	ConversationType             string
	ExpiresAt                    *time.Time
}

type ElicitationFormMapping added in v0.20.0

type ElicitationFormMapping struct {
	// contains filtered or unexported fields
}

func ElicitationFormInput added in v0.20.0

func ElicitationFormInput(message string, schema map[string]any) (map[string]any, ElicitationFormMapping, error)

func (ElicitationFormMapping) Content added in v0.20.0

func (m ElicitationFormMapping) Content(req Request) (map[string]any, error)

type FlowRequest

type FlowRequest struct {
	Input CreatePendingInput
	// ActorChannelIdentityID is recorded on system cancels.
	ActorChannelIdentityID string
	Interactive            bool
	WaitTimeout            time.Duration
	Emit                   func(Request) bool
	NonInteractiveReason   string
	UndeliveredReason      string
	TimeoutReason          string
	AbortReason            string
}

type FlowResult

type FlowResult struct {
	Request Request
}

func RunFlow

func RunFlow(ctx context.Context, svc FlowService, flow FlowRequest) (FlowResult, error)

RunFlow executes the blocking ask_user state machine shared by native tool runtimes: create the pending request, publish it, wait for a response, and cancel on timeout, abort, or delivery failure.

type FlowService

type FlowService interface {
	CreatePending(ctx context.Context, input CreatePendingInput) (Request, error)
	Cancel(ctx context.Context, input CancelInput) (Request, error)
	WaitForRegisteredResponse(ctx context.Context, requestID string) (Request, error)
	RegisterWaiter(requestID string) func()
}

FlowService is the subset of Service needed by the shared ask_user flow.

type InteractionOp

type InteractionOp struct {
	Kind InteractionOpKind
	// QuestionIndex/OptionIndex address buttons for select/toggle; indexes
	// are stable because UIPayload question/option order never changes after
	// CreatePending.
	QuestionIndex int
	OptionIndex   int
	// Page is the navigate target.
	Page int
	// QuestionID binds set_text to the question that requested free text,
	// not to the current cursor — navigation may have moved since the
	// prompt was issued.
	QuestionID string
	Text       string
}

type InteractionOpKind

type InteractionOpKind string

Structured ask_user gestures from channels with native controls (inline buttons). They drive the same durable TextInteractionState as plain-text replies (AdvanceText) — one state machine, two input surfaces — so an interaction survives process restarts regardless of which surface it uses.

const (
	// OpSelectOption answers a single_select question and auto-advances;
	// re-selecting the current choice clears it (toggle-off).
	OpSelectOption InteractionOpKind = "select"
	// OpToggleOption flips one multi_select option; never advances.
	OpToggleOption InteractionOpKind = "toggle"
	// OpSetText records a verbatim free-text answer (text question or
	// allow_custom). Unlike AdvanceText it never option-matches the input:
	// the user explicitly chose to type, so the text is taken as-is.
	OpSetText InteractionOpKind = "set_text"
	// OpNavigate moves the question cursor without touching answers.
	OpNavigate InteractionOpKind = "navigate"
	// OpSubmit completes the request, marking unanswered questions skipped.
	OpSubmit InteractionOpKind = "submit"
)

type InteractionOutcome

type InteractionOutcome struct {
	Changed bool
	Reject  InteractionReject
}

type InteractionReject

type InteractionReject string

InteractionReject explains why an op did not apply. Rejections are user-correctable (wrong button state, empty text) — not errors.

const (
	RejectNone             InteractionReject = ""
	RejectInvalidOp        InteractionReject = "invalid_op"
	RejectCustomNotAllowed InteractionReject = "custom_not_allowed"
	RejectEmptyText        InteractionReject = "empty_text"
)

type QuestionAnswer

type QuestionAnswer struct {
	QuestionID string   `json:"question_id"`
	OptionIDs  []string `json:"option_ids,omitempty"`
	CustomText string   `json:"custom_text,omitempty"`
	Text       string   `json:"text,omitempty"`
	Skipped    bool     `json:"skipped,omitempty"`
}

QuestionAnswer is the user's answer to a single question of an ask_user request. Selections are always arrays; single_select is an array of one.

type Request

type Request struct {
	ID                      string               `json:"id"`
	BotID                   string               `json:"bot_id"`
	SessionID               string               `json:"session_id"`
	RouteID                 string               `json:"route_id,omitempty"`
	ChannelIdentityID       string               `json:"channel_identity_id,omitempty"`
	WorkspaceTargetID       string               `json:"workspace_target_id,omitempty"`
	ToolCallID              string               `json:"tool_call_id"`
	ToolName                string               `json:"tool_name"`
	ShortID                 int                  `json:"short_id"`
	Status                  string               `json:"status"`
	Input                   map[string]any       `json:"input,omitempty"`
	UIPayload               UIPayload            `json:"ui_payload"`
	Interaction             TextInteractionState `json:"interaction"`
	InteractionRevision     int                  `json:"interaction_revision"`
	Result                  map[string]any       `json:"result,omitempty"`
	ProviderMetadata        map[string]any       `json:"-"`
	PromptExternalMessageID string               `json:"prompt_external_message_id,omitempty"`
	SourcePlatform          string               `json:"source_platform,omitempty"`
	ReplyTarget             string               `json:"reply_target,omitempty"`
	ConversationType        string               `json:"conversation_type,omitempty"`
	ExpiresAt               *time.Time           `json:"expires_at,omitempty"`
	CreatedAt               time.Time            `json:"created_at"`
	RespondedAt             *time.Time           `json:"responded_at,omitempty"`
	CanceledAt              *time.Time           `json:"canceled_at,omitempty"`
	RuntimeFenced           bool                 `json:"-"`
}

type ResolveInput

type ResolveInput struct {
	BotID                  string
	SessionID              string
	ExplicitID             string
	ReplyExternalMessageID string
}

type Service

type Service struct {
	// contains filtered or unexported fields
}

func NewService

func NewService(log *slog.Logger, queries dbstore.Queries) *Service

func (*Service) AdvanceInteraction

func (s *Service) AdvanceInteraction(ctx context.Context, input AdvanceInteractionInput) (AdvanceInteractionResult, error)

AdvanceInteraction applies one structured op to the durable interaction state under the same optimistic-lock CAS as AdvanceText: each attempt re-reads the row and re-applies the op, so a concurrent writer never makes a stale gesture win.

func (*Service) AdvanceText

func (s *Service) AdvanceText(ctx context.Context, input AdvanceTextInput) (AdvanceTextResult, error)

AdvanceText consumes one plain-text reply without submitting the request. The final answer set is persisted first so a process crash before Submit can resume the same completion instead of losing earlier questions.

func (*Service) CanRespond

func (s *Service) CanRespond(req Request) bool

CanRespond reports whether a waiter-backed request can accept a response in this process. Native chat requests are DB-deferred and must not use this helper; callers classify by the session's runtime (UsesDecisionWaiter), the same way tool approvals do.

func (*Service) Cancel

func (s *Service) Cancel(ctx context.Context, input CancelInput) (Request, error)

func (*Service) CancelPendingForRun added in v0.20.0

func (s *Service) CancelPendingForRun(ctx context.Context, botID, sessionID, runID string, fencingToken int64, reason string) ([]Request, error)

CancelPendingForRun invalidates only pending ask_user requests owned by one exact run. It is used by runtime recovery after a run is declared lost; session-wide cancellation would incorrectly expire a newer run's request.

func (*Service) CancelPendingForSession

func (s *Service) CancelPendingForSession(ctx context.Context, botID, sessionID, reason string) ([]Request, error)

func (*Service) CreatePending

func (s *Service) CreatePending(ctx context.Context, input CreatePendingInput) (Request, error)

func (*Service) Fail

func (s *Service) Fail(ctx context.Context, requestID string, result map[string]any) (Request, error)

func (*Service) Get

func (s *Service) Get(ctx context.Context, requestID string) (Request, error)

func (*Service) HasWaiter

func (s *Service) HasWaiter(requestID string) bool

HasWaiter reports whether anyone in this process is currently registered for the request. It is only a local fast-path signal; DB status remains the cross-process source of truth for whether a request can accept a response.

func (*Service) ListBySession

func (s *Service) ListBySession(ctx context.Context, botID, sessionID string) ([]Request, error)

func (*Service) ListBySessionToolCalls

func (s *Service) ListBySessionToolCalls(ctx context.Context, botID, sessionID string, toolCallIDs []string) ([]Request, error)

func (*Service) ListPendingBySession

func (s *Service) ListPendingBySession(ctx context.Context, botID, sessionID string) ([]Request, error)

func (*Service) RegisterWaiter

func (s *Service) RegisterWaiter(requestID string) func()

RegisterWaiter records that a caller in this process owns the request's resolution. Callers that announce a pending request to users must register BEFORE announcing, or an instant response can be misjudged as orphaned. The returned release must run when the wait ends.

func (*Service) ResolveTarget

func (s *Service) ResolveTarget(ctx context.Context, input ResolveInput) (Request, error)

func (*Service) Submit

func (s *Service) Submit(ctx context.Context, input SubmitInput) (Request, error)

func (*Service) UpdateAssistantMessage

func (s *Service) UpdateAssistantMessage(ctx context.Context, requestID, messageID string) (Request, error)

func (*Service) UpdatePromptMessage

func (s *Service) UpdatePromptMessage(ctx context.Context, requestID, promptMessageID, externalID string) (Request, error)

func (*Service) UpdateToolResultMessage

func (s *Service) UpdateToolResultMessage(ctx context.Context, requestID, messageID string) (Request, error)

func (*Service) WaitForRegisteredResponse

func (s *Service) WaitForRegisteredResponse(ctx context.Context, requestID string) (Request, error)

WaitForRegisteredResponse waits like WaitForResponse but assumes the caller already registered with RegisterWaiter before announcing the request.

func (*Service) WaitForResponse

func (s *Service) WaitForResponse(ctx context.Context, requestID string) (Request, error)

WaitForResponse blocks until the request leaves pending. Resolution inside this process arrives via the Submit/Cancel/Fail broadcast; the slow ticker is only a safety net for transitions this process cannot observe (another node, manual DB changes, time-based expiry).

type SubmitInput

type SubmitInput struct {
	RequestID              string
	ActorChannelIdentityID string
	Answers                []QuestionAnswer
}

type TextInteractionState

type TextInteractionState struct {
	QuestionIndex int              `json:"question_index"`
	Answers       []QuestionAnswer `json:"answers,omitempty"`
	Completed     bool             `json:"completed,omitempty"`
}

TextInteractionState is the durable ask_user cursor shared by every input surface — plain-text replies (AdvanceText) and native buttons (AdvanceInteraction). Answers remain present when the user moves backward.

func (TextInteractionState) Answer

func (s TextInteractionState) Answer(questionID string) (QuestionAnswer, bool)

Answer returns the saved answer for a question. ok is false when the user has not answered it yet (a persisted skip still counts as answered).

type UIAnswer

type UIAnswer struct {
	QuestionID string     `json:"question_id"`
	Question   string     `json:"question"`
	Selected   []UIOption `json:"selected,omitempty"`
	CustomText string     `json:"custom_text,omitempty"`
	Text       string     `json:"text,omitempty"`
	Skipped    bool       `json:"skipped,omitempty"`

} // @name userinput.UIAnswer

UIAnswer is the public, display-safe projection of one submitted answer. Model-only instructions remain in Request.Result and are never exposed here.

func AnswersFromResult

func AnswersFromResult(result map[string]any) []UIAnswer

AnswersFromResult returns only the display-safe answer fields from a persisted tool result. Internal instructions and failure diagnostics stay private to the runtime/model boundary.

func AnswersFromStored

func AnswersFromStored(value any) []UIAnswer

AnswersFromStored normalizes either in-memory or JSON-decoded answer data.

type UIOption

type UIOption struct {
	LabelKey    string `json:"label_key,omitempty"`
	ID          string `json:"id"`
	Label       string `json:"label"`
	Description string `json:"description,omitempty"`

} // @name userinput.UIOption

UIOption describes one selectable option in an ask_user question.

type UIPayload

type UIPayload struct {
	Version   int          `json:"version"`
	Questions []UIQuestion `json:"questions"`
}

UIPayload is the canonical, normalized ask_user payload (v2). It is the single shape stored, streamed, and rendered; ParseAskUserPayload is the only writer and PayloadFromStored the only reader.

func ParseAskUserPayload

func ParseAskUserPayload(input any) (UIPayload, error)

ParseAskUserPayload is the single write-side entry point for ask_user arguments. It validates strictly (no aliases, no inference) and returns the canonical v2 payload with server-generated question/option IDs.

func PayloadFromStored

func PayloadFromStored(value any) UIPayload

PayloadFromStored is the single read-side entry point. It decodes a stored or streamed ui_payload value, upgrading legacy (pre-v2) rows so the rest of the system only ever sees the canonical shape. It is tolerant: stored data must keep rendering even when it predates current validation.

func (UIPayload) Localized added in v0.20.0

func (p UIPayload) Localized(loc *i18n.Localizer) UIPayload

Localized returns a display copy. Stored payloads and response identities remain independent of the channel's current UI language.

func (UIPayload) Question

func (p UIPayload) Question(id string) (UIQuestion, bool)

type UIQuestion

type UIQuestion struct {
	ID              string     `json:"id"`
	Text            string     `json:"text"`
	Kind            string     `json:"kind"`
	Options         []UIOption `json:"options,omitempty"`
	AllowCustom     bool       `json:"allow_custom,omitempty"`
	CustomExclusive bool       `json:"custom_exclusive,omitempty"`
	// Required is tri-state for compatibility. Legacy/native ask_user payloads
	// omit it and keep their existing surface semantics; ACP forms set it
	// explicitly so false means an optional schema property.
	Required    *bool  `json:"required,omitempty"`
	Placeholder string `json:"placeholder,omitempty"`

} // @name userinput.UIQuestion

UIQuestion describes one question in the ask_user UI payload.

func (UIQuestion) Option

func (q UIQuestion) Option(id string) (UIOption, bool)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL