635959f56d
Co-authored-by: Kandev Agent <agent@kandev.dev>
573 lines
20 KiB
Go
573 lines
20 KiB
Go
// interactions.go is the Go-native side of the Host interaction API (ADR
|
|
// 0052): the durable record of every agent request still owed a human answer,
|
|
// plus the capability-gated writes that answer them.
|
|
//
|
|
// It exists because session state cannot answer "does a human owe this session
|
|
// something?". WAITING_FOR_INPUT also describes an ordinarily completed turn,
|
|
// so a state-only plugin reports attention nobody owes. Events alone cannot
|
|
// answer it either — a plugin may start after the request, restart, or drop an
|
|
// event. Only the durable record can, which is what ListPending returns and
|
|
// what Get resolves for any id, terminal ones included.
|
|
//
|
|
// Like ExecutorProfileHost and PluginOwnedTaskTreeHost, this is an OPTIONAL
|
|
// Host extension rather than a method on Host itself, so every existing
|
|
// Go-native Host implementation stays source-compatible.
|
|
package pluginsdk
|
|
|
|
import (
|
|
"context"
|
|
|
|
pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// Interaction kind values. A plugin should compare against these rather than
|
|
// bare strings; new kinds are additive.
|
|
const (
|
|
InteractionKindPermission = "permission"
|
|
InteractionKindClarification = "clarification"
|
|
)
|
|
|
|
// Interaction status values. Anything other than InteractionStatusPending is
|
|
// terminal: the interaction can no longer be answered, and a write against it
|
|
// returns a gRPC FailedPrecondition.
|
|
const (
|
|
InteractionStatusPending = "pending"
|
|
InteractionStatusApproved = "approved"
|
|
InteractionStatusRejected = "rejected"
|
|
InteractionStatusAnswered = "answered"
|
|
InteractionStatusCancelled = "cancelled"
|
|
InteractionStatusExpired = "expired"
|
|
)
|
|
|
|
// InteractionOption is one selectable response. Kind carries the ACP
|
|
// permission-option kind on a permission ("allow_once", "allow_always",
|
|
// "reject_once", "reject_always") and is empty on a clarification option;
|
|
// Description is the reverse.
|
|
type InteractionOption struct {
|
|
OptionID string
|
|
Label string
|
|
Kind string
|
|
Description string
|
|
}
|
|
|
|
// InteractionQuestion is one question of a clarification bundle. Every
|
|
// question must be answered before the agent unblocks.
|
|
type InteractionQuestion struct {
|
|
ID string
|
|
Title string
|
|
Prompt string
|
|
Options []InteractionOption
|
|
}
|
|
|
|
// Interaction is one thing a human still owes an answer to. ID is the pending
|
|
// id every response method keys on — the same id the permission and
|
|
// clarification bus events carry, so an event-driven cache can reconcile
|
|
// against a ListPending snapshot without inventing a correlation key.
|
|
//
|
|
// AgentDisconnected marks a still-pending clarification whose original waiter
|
|
// has gone away. It remains answerable (the answer resumes the session in a
|
|
// new turn), so it is NOT a resolution — treating it as one is exactly the
|
|
// false negative the durable record exists to prevent.
|
|
type Interaction struct {
|
|
ID string
|
|
Kind string // InteractionKind* constant
|
|
TaskID string
|
|
SessionID string
|
|
TurnID string
|
|
Status string // InteractionStatus* constant
|
|
Title string
|
|
Context string
|
|
ToolCallID string
|
|
ActionType string
|
|
Options []InteractionOption // permission options
|
|
Questions []InteractionQuestion // clarification questions
|
|
AgentDisconnected bool
|
|
CreatedAt string // RFC3339
|
|
UpdatedAt string // RFC3339
|
|
}
|
|
|
|
// InteractionFilter narrows ListPending. SessionIDs and TaskIDs are ORed
|
|
// within each axis and ANDed across the two; an empty axis is unconstrained.
|
|
// Kinds narrows by interaction kind; empty means both.
|
|
type InteractionFilter struct {
|
|
SessionIDs []string
|
|
TaskIDs []string
|
|
Kinds []string
|
|
}
|
|
|
|
// PermissionResponse names the option a user chose for a permission request.
|
|
// kandev derives the approve/deny outcome from that option's recorded kind, so
|
|
// a plugin cannot report an outcome the agent never offered. Set Cancelled to
|
|
// dismiss the request instead of choosing (OptionID must then be empty).
|
|
type PermissionResponse struct {
|
|
InteractionID string
|
|
OptionID string
|
|
Cancelled bool
|
|
}
|
|
|
|
// ClarificationAnswer is a user's answer to one question of a bundle.
|
|
type ClarificationAnswer struct {
|
|
QuestionID string
|
|
SelectedOptions []string
|
|
CustomText string
|
|
}
|
|
|
|
// ClarificationResponse answers a whole clarification bundle. Answers must
|
|
// contain exactly one entry per question in the interaction.
|
|
type ClarificationResponse struct {
|
|
InteractionID string
|
|
Answers []ClarificationAnswer
|
|
}
|
|
|
|
// InteractionHost is the optional Host extension exposing Interactions().
|
|
type InteractionHost interface {
|
|
Interactions() InteractionAccessor
|
|
}
|
|
|
|
// InteractionAccessor is the accessor behind Host.Interactions(). Reads
|
|
// (ListPending/Get) require api_read:interactions; writes require
|
|
// api_write:interactions. The two gate independently, so an attention or inbox
|
|
// plugin can declare read-only.
|
|
//
|
|
// Every write is terminal-once: the first response wins, and a later attempt
|
|
// against an already-resolved interaction returns gRPC FailedPrecondition
|
|
// rather than dispatching a second response to the agent. An unknown id
|
|
// returns NotFound. Those two codes are what let a reconciling cache tell
|
|
// "someone else answered" apart from "my id is stale".
|
|
type InteractionAccessor interface {
|
|
// ListPending returns every interaction still owed a response, under the
|
|
// same turn/session authority kandev's own list surfaces use.
|
|
ListPending(ctx context.Context, filter InteractionFilter, page Page) ([]Interaction, *PageInfo, error)
|
|
|
|
// Get resolves one interaction by pending id whatever its status,
|
|
// terminal ones included, so a replayed or missed event converges on the
|
|
// current durable result instead of disappearing.
|
|
Get(ctx context.Context, id string) (*Interaction, error)
|
|
|
|
// RespondToPermission answers a permission request through kandev's
|
|
// orchestrator — the same path the native UI uses, so the agent unblocks
|
|
// and every surface converges. Returns the interaction's new terminal state.
|
|
RespondToPermission(ctx context.Context, in PermissionResponse) (*Interaction, error)
|
|
|
|
// AnswerClarification answers every question of a clarification bundle.
|
|
AnswerClarification(ctx context.Context, in ClarificationResponse) (*Interaction, error)
|
|
|
|
// CancelClarification declines a clarification bundle on the user's
|
|
// behalf, surfacing reason to the agent. Works whether or not the original
|
|
// waiter is still parked.
|
|
CancelClarification(ctx context.Context, id, reason string) (*Interaction, error)
|
|
}
|
|
|
|
// Interactions returns the optional interaction accessor, and false when the
|
|
// host predates the extension.
|
|
func Interactions(host Host) (InteractionAccessor, bool) {
|
|
provider, ok := host.(InteractionHost)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return provider.Interactions(), true
|
|
}
|
|
|
|
// ── proto conversion ────────────────────────────────────────────────────────
|
|
|
|
func (o InteractionOption) toProto() *pluginv1.InteractionOption {
|
|
return &pluginv1.InteractionOption{
|
|
OptionId: o.OptionID, Label: o.Label, Kind: o.Kind, Description: o.Description,
|
|
}
|
|
}
|
|
|
|
func interactionOptionFromProto(p *pluginv1.InteractionOption) InteractionOption {
|
|
if p == nil {
|
|
return InteractionOption{}
|
|
}
|
|
return InteractionOption{
|
|
OptionID: p.GetOptionId(), Label: p.GetLabel(), Kind: p.GetKind(), Description: p.GetDescription(),
|
|
}
|
|
}
|
|
|
|
func interactionOptionsToProto(items []InteractionOption) []*pluginv1.InteractionOption {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*pluginv1.InteractionOption, len(items))
|
|
for i := range items {
|
|
out[i] = items[i].toProto()
|
|
}
|
|
return out
|
|
}
|
|
|
|
func interactionOptionsFromProto(items []*pluginv1.InteractionOption) []InteractionOption {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]InteractionOption, len(items))
|
|
for i, item := range items {
|
|
out[i] = interactionOptionFromProto(item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (q InteractionQuestion) toProto() *pluginv1.InteractionQuestion {
|
|
return &pluginv1.InteractionQuestion{
|
|
Id: q.ID, Title: q.Title, Prompt: q.Prompt, Options: interactionOptionsToProto(q.Options),
|
|
}
|
|
}
|
|
|
|
func interactionQuestionFromProto(p *pluginv1.InteractionQuestion) InteractionQuestion {
|
|
if p == nil {
|
|
return InteractionQuestion{}
|
|
}
|
|
return InteractionQuestion{
|
|
ID: p.GetId(),
|
|
Title: p.GetTitle(),
|
|
Prompt: p.GetPrompt(),
|
|
Options: interactionOptionsFromProto(p.GetOptions()),
|
|
}
|
|
}
|
|
|
|
func (i Interaction) toProto() *pluginv1.Interaction {
|
|
questions := make([]*pluginv1.InteractionQuestion, 0, len(i.Questions))
|
|
for _, question := range i.Questions {
|
|
questions = append(questions, question.toProto())
|
|
}
|
|
if len(questions) == 0 {
|
|
questions = nil
|
|
}
|
|
return &pluginv1.Interaction{
|
|
Id: i.ID,
|
|
Kind: i.Kind,
|
|
TaskId: i.TaskID,
|
|
SessionId: i.SessionID,
|
|
TurnId: i.TurnID,
|
|
Status: i.Status,
|
|
Title: i.Title,
|
|
Context: i.Context,
|
|
ToolCallId: i.ToolCallID,
|
|
ActionType: i.ActionType,
|
|
Options: interactionOptionsToProto(i.Options),
|
|
Questions: questions,
|
|
AgentDisconnected: i.AgentDisconnected,
|
|
CreatedAt: i.CreatedAt,
|
|
UpdatedAt: i.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func interactionFromProto(p *pluginv1.Interaction) Interaction {
|
|
if p == nil {
|
|
return Interaction{}
|
|
}
|
|
protoQuestions := p.GetQuestions()
|
|
var questions []InteractionQuestion
|
|
if len(protoQuestions) > 0 {
|
|
questions = make([]InteractionQuestion, len(protoQuestions))
|
|
for i, question := range protoQuestions {
|
|
questions[i] = interactionQuestionFromProto(question)
|
|
}
|
|
}
|
|
return Interaction{
|
|
ID: p.GetId(),
|
|
Kind: p.GetKind(),
|
|
TaskID: p.GetTaskId(),
|
|
SessionID: p.GetSessionId(),
|
|
TurnID: p.GetTurnId(),
|
|
Status: p.GetStatus(),
|
|
Title: p.GetTitle(),
|
|
Context: p.GetContext(),
|
|
ToolCallID: p.GetToolCallId(),
|
|
ActionType: p.GetActionType(),
|
|
Options: interactionOptionsFromProto(p.GetOptions()),
|
|
Questions: questions,
|
|
AgentDisconnected: p.GetAgentDisconnected(),
|
|
CreatedAt: p.GetCreatedAt(),
|
|
UpdatedAt: p.GetUpdatedAt(),
|
|
}
|
|
}
|
|
|
|
func interactionsToProto(items []Interaction) []*pluginv1.Interaction {
|
|
out := make([]*pluginv1.Interaction, len(items))
|
|
for i := range items {
|
|
out[i] = items[i].toProto()
|
|
}
|
|
return out
|
|
}
|
|
|
|
func interactionsFromProto(items []*pluginv1.Interaction) []Interaction {
|
|
out := make([]Interaction, len(items))
|
|
for i, item := range items {
|
|
out[i] = interactionFromProto(item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f InteractionFilter) toProto() *pluginv1.InteractionFilter {
|
|
return &pluginv1.InteractionFilter{
|
|
SessionIds: f.SessionIDs, TaskIds: f.TaskIDs, Kinds: f.Kinds,
|
|
}
|
|
}
|
|
|
|
func interactionFilterFromProto(p *pluginv1.InteractionFilter) InteractionFilter {
|
|
if p == nil {
|
|
return InteractionFilter{}
|
|
}
|
|
return InteractionFilter{
|
|
SessionIDs: p.GetSessionIds(), TaskIDs: p.GetTaskIds(), Kinds: p.GetKinds(),
|
|
}
|
|
}
|
|
|
|
func clarificationAnswersToProto(items []ClarificationAnswer) []*pluginv1.ClarificationAnswer {
|
|
out := make([]*pluginv1.ClarificationAnswer, len(items))
|
|
for i, item := range items {
|
|
out[i] = &pluginv1.ClarificationAnswer{
|
|
QuestionId: item.QuestionID,
|
|
SelectedOptions: item.SelectedOptions,
|
|
CustomText: item.CustomText,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func clarificationAnswersFromProto(items []*pluginv1.ClarificationAnswer) []ClarificationAnswer {
|
|
out := make([]ClarificationAnswer, len(items))
|
|
for i, item := range items {
|
|
out[i] = ClarificationAnswer{
|
|
QuestionID: item.GetQuestionId(),
|
|
SelectedOptions: item.GetSelectedOptions(),
|
|
CustomText: item.GetCustomText(),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── plugin-side client ──────────────────────────────────────────────────────
|
|
|
|
type grpcInteractionAccessor struct {
|
|
client pluginv1.HostClient
|
|
}
|
|
|
|
func (a grpcInteractionAccessor) ListPending(
|
|
ctx context.Context, filter InteractionFilter, page Page,
|
|
) ([]Interaction, *PageInfo, error) {
|
|
resp, err := a.client.ListPendingInteractions(ctx, &pluginv1.ListPendingInteractionsRequest{
|
|
Filter: filter.toProto(), Page: page.toProto(),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return interactionsFromProto(resp.GetInteractions()), pageInfoFromProto(resp.GetPageInfo()), nil
|
|
}
|
|
|
|
func (a grpcInteractionAccessor) Get(ctx context.Context, id string) (*Interaction, error) {
|
|
resp, err := a.client.GetInteraction(ctx, &pluginv1.GetInteractionRequest{Id: id})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interaction := interactionFromProto(resp.GetInteraction())
|
|
return &interaction, nil
|
|
}
|
|
|
|
func (a grpcInteractionAccessor) RespondToPermission(
|
|
ctx context.Context, in PermissionResponse,
|
|
) (*Interaction, error) {
|
|
resp, err := a.client.RespondToPermission(ctx, &pluginv1.RespondToPermissionRequest{
|
|
Id: in.InteractionID, OptionId: in.OptionID, Cancelled: in.Cancelled,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interaction := interactionFromProto(resp.GetInteraction())
|
|
return &interaction, nil
|
|
}
|
|
|
|
func (a grpcInteractionAccessor) AnswerClarification(
|
|
ctx context.Context, in ClarificationResponse,
|
|
) (*Interaction, error) {
|
|
resp, err := a.client.AnswerClarification(ctx, &pluginv1.AnswerClarificationRequest{
|
|
Id: in.InteractionID, Answers: clarificationAnswersToProto(in.Answers),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interaction := interactionFromProto(resp.GetInteraction())
|
|
return &interaction, nil
|
|
}
|
|
|
|
func (a grpcInteractionAccessor) CancelClarification(
|
|
ctx context.Context, id, reason string,
|
|
) (*Interaction, error) {
|
|
resp, err := a.client.CancelClarification(ctx, &pluginv1.CancelClarificationRequest{
|
|
Id: id, Reason: reason,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interaction := interactionFromProto(resp.GetInteraction())
|
|
return &interaction, nil
|
|
}
|
|
|
|
// ── kandev-side server ──────────────────────────────────────────────────────
|
|
|
|
// interactionAccessor resolves the impl's optional interaction extension,
|
|
// returning the same Unimplemented error the other optional extensions use
|
|
// when the host predates it.
|
|
func (s *grpcHostServer) interactionAccessor() (InteractionAccessor, error) {
|
|
provider, ok := s.impl.(InteractionHost)
|
|
if !ok {
|
|
return nil, errUnimplementedHostData(resourceNameInteractions)
|
|
}
|
|
return provider.Interactions(), nil
|
|
}
|
|
|
|
// resourceNameInteractions is the resource label in the Unimplemented error;
|
|
// it mirrors the api_read:interactions / api_write:interactions capability
|
|
// names the kandev-side host gates on.
|
|
const resourceNameInteractions = "interactions"
|
|
|
|
func (s *grpcHostServer) ListPendingInteractions(
|
|
ctx context.Context, req *pluginv1.ListPendingInteractionsRequest,
|
|
) (*pluginv1.ListPendingInteractionsResponse, error) {
|
|
accessor, err := s.interactionAccessor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interactions, pageInfo, err := accessor.ListPending(
|
|
ctx, interactionFilterFromProto(req.GetFilter()), pageFromProto(req.GetPage()),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &pluginv1.ListPendingInteractionsResponse{
|
|
Interactions: interactionsToProto(interactions), PageInfo: pageInfo.toProto(),
|
|
}, nil
|
|
}
|
|
|
|
func (s *grpcHostServer) GetInteraction(
|
|
ctx context.Context, req *pluginv1.GetInteractionRequest,
|
|
) (*pluginv1.GetInteractionResponse, error) {
|
|
accessor, err := s.interactionAccessor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interaction, err := accessor.Get(ctx, req.GetId())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if interaction == nil {
|
|
return nil, interactionNotFound(req.GetId())
|
|
}
|
|
return &pluginv1.GetInteractionResponse{Interaction: interaction.toProto()}, nil
|
|
}
|
|
|
|
func (s *grpcHostServer) RespondToPermission(
|
|
ctx context.Context, req *pluginv1.RespondToPermissionRequest,
|
|
) (*pluginv1.RespondToPermissionResponse, error) {
|
|
accessor, err := s.interactionAccessor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interaction, err := accessor.RespondToPermission(ctx, PermissionResponse{
|
|
InteractionID: req.GetId(), OptionID: req.GetOptionId(), Cancelled: req.GetCancelled(),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if interaction == nil {
|
|
return nil, interactionNotFound(req.GetId())
|
|
}
|
|
return &pluginv1.RespondToPermissionResponse{Interaction: interaction.toProto()}, nil
|
|
}
|
|
|
|
func (s *grpcHostServer) AnswerClarification(
|
|
ctx context.Context, req *pluginv1.AnswerClarificationRequest,
|
|
) (*pluginv1.AnswerClarificationResponse, error) {
|
|
accessor, err := s.interactionAccessor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interaction, err := accessor.AnswerClarification(ctx, ClarificationResponse{
|
|
InteractionID: req.GetId(), Answers: clarificationAnswersFromProto(req.GetAnswers()),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if interaction == nil {
|
|
return nil, interactionNotFound(req.GetId())
|
|
}
|
|
return &pluginv1.AnswerClarificationResponse{Interaction: interaction.toProto()}, nil
|
|
}
|
|
|
|
func (s *grpcHostServer) CancelClarification(
|
|
ctx context.Context, req *pluginv1.CancelClarificationRequest,
|
|
) (*pluginv1.CancelClarificationResponse, error) {
|
|
accessor, err := s.interactionAccessor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
interaction, err := accessor.CancelClarification(ctx, req.GetId(), req.GetReason())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if interaction == nil {
|
|
return nil, interactionNotFound(req.GetId())
|
|
}
|
|
return &pluginv1.CancelClarificationResponse{Interaction: interaction.toProto()}, nil
|
|
}
|
|
|
|
// ── unimplemented default ───────────────────────────────────────────────────
|
|
|
|
// Interactions is the embeddable default on UnimplementedHostData, so a Host
|
|
// that has not wired the interaction API still satisfies InteractionHost and
|
|
// answers Unimplemented rather than panicking.
|
|
func (UnimplementedHostData) Interactions() InteractionAccessor {
|
|
return unimplementedInteractionAccessor{}
|
|
}
|
|
|
|
type unimplementedInteractionAccessor struct{}
|
|
|
|
func (unimplementedInteractionAccessor) ListPending(
|
|
context.Context, InteractionFilter, Page,
|
|
) ([]Interaction, *PageInfo, error) {
|
|
return nil, nil, errUnimplementedHostData(resourceNameInteractions)
|
|
}
|
|
|
|
func (unimplementedInteractionAccessor) Get(context.Context, string) (*Interaction, error) {
|
|
return nil, errUnimplementedHostData(resourceNameInteractions)
|
|
}
|
|
|
|
func (unimplementedInteractionAccessor) RespondToPermission(
|
|
context.Context, PermissionResponse,
|
|
) (*Interaction, error) {
|
|
return nil, errUnimplementedHostData(resourceNameInteractions)
|
|
}
|
|
|
|
func (unimplementedInteractionAccessor) AnswerClarification(
|
|
context.Context, ClarificationResponse,
|
|
) (*Interaction, error) {
|
|
return nil, errUnimplementedHostData(resourceNameInteractions)
|
|
}
|
|
|
|
func (unimplementedInteractionAccessor) CancelClarification(
|
|
context.Context, string, string,
|
|
) (*Interaction, error) {
|
|
return nil, errUnimplementedHostData(resourceNameInteractions)
|
|
}
|
|
|
|
// grpcHostClient satisfies the optional extension so a plugin reaches the
|
|
// Interactions accessor through the same pluginsdk.Interactions(host) helper
|
|
// kandev-side callers use.
|
|
func (h *grpcHostClient) Interactions() InteractionAccessor {
|
|
return grpcInteractionAccessor{client: h.client}
|
|
}
|
|
|
|
var _ InteractionHost = (*grpcHostClient)(nil)
|
|
|
|
// interactionNotFound is the error every interaction RPC returns for an id
|
|
// that resolves to nothing. Defense-in-depth for the trust boundary: the
|
|
// in-tree host never returns (nil, nil) on success, but a custom or test host
|
|
// might, and a plugin should get NotFound rather than a zero-value DTO that
|
|
// reads as a real, pending interaction.
|
|
func interactionNotFound(id string) error {
|
|
return status.Errorf(codes.NotFound, "interaction %q not found", id)
|
|
}
|