internal/approvalgate/turn_gate.go
Rama main · réplica de lectura del repositorio original.
package approvalgate
import (
"context"
"encoding/json"
"strings"
"github.com/yeomyeonggeori/blueclaw/internal/mcpserver"
"github.com/yeomyeonggeori/blueprotocol/toolcontract"
)
type TurnContext struct {
RequesterPersonID string
RequesterEmail string
ResponseLanguage string
Prompt string
Platform string
ConversationID string
ConversationType string
ChannelID string
ReplyTargetID string
HarnessSession mcpserver.HarnessSession
}
type turnToolCallGate struct {
gate *Gate
turnContext TurnContext
}
func (gate *Gate) TurnGate(turnContext TurnContext) toolcontract.ToolCallGate {
return turnToolCallGate{gate: gate, turnContext: turnContext}
}
func (turnGate turnToolCallGate) ReviewToolCall(ctx context.Context, toolInvocation toolcontract.ToolInvocation, toolDefinition toolcontract.ToolDefinition) (toolcontract.ToolCallReview, error) {
if !callNeedsApproval(toolDefinition, toolInvocation.Input) {
return toolcontract.ToolCallReview{MayProceed: true}, nil
}
if landsInTheConversationBeingAnswered(turnGate.turnContext, toolDefinition, toolInvocation.Input) {
return toolcontract.ToolCallReview{MayProceed: true}, nil
}
if toolcontract.IsDelegatedTurn(ctx) {
return toolcontract.ToolCallReview{Result: delegatedTurnDeniedResult()}, nil
}
if turnGate.gate == nil {
return toolcontract.ToolCallReview{Result: unanswerableCallResult()}, nil
}
outcome, errorValue := turnGate.gate.AwaitApproval(ctx, mcpserver.ApprovalRequest{
RequesterPersonID: turnGate.turnContext.RequesterPersonID,
RequesterEmail: turnGate.turnContext.RequesterEmail,
TaskRunID: taskRunIDForCall(ctx),
ResponseLanguage: turnGate.turnContext.ResponseLanguage,
Prompt: turnGate.turnContext.Prompt,
Platform: turnGate.turnContext.Platform,
ConversationID: turnGate.turnContext.ConversationID,
ReplyTargetID: turnGate.turnContext.ReplyTargetID,
ModelDraft: toolcontract.UserFacingMessageFromContext(ctx),
HarnessSession: turnGate.turnContext.HarnessSession,
ToolName: toolDefinition.Name,
ToolInput: toolInvocation.Input,
ApprovalInputFields: toolDefinition.ApprovalInputFields,
SideEffectClass: strings.TrimSpace(toolDefinition.SideEffectClass),
})
if errorValue != nil {
return toolcontract.ToolCallReview{Result: HeldCallResult(errorValue.Error())}, nil
}
switch outcome.Decision {
case mcpserver.ApprovalDecisionApproved:
return toolcontract.ToolCallReview{MayProceed: true, HoldID: outcome.HoldID}, nil
case mcpserver.ApprovalDecisionRejected:
return toolcontract.ToolCallReview{Result: rejectedCallResult(outcome.Notice)}, nil
case mcpserver.ApprovalDecisionUnanswerable:
return toolcontract.ToolCallReview{Result: unanswerableCallResult()}, nil
case mcpserver.ApprovalDecisionUnresolvedTarget:
return toolcontract.ToolCallReview{Result: outcome.Failure}, nil
case mcpserver.ApprovalDecisionDeferred:
return toolcontract.ToolCallReview{Result: outcome.Deferral}, nil
}
return toolcontract.ToolCallReview{Result: HeldCallResult(outcome.Notice)}, nil
}
func taskRunIDForCall(ctx context.Context) string {
return strings.TrimSpace(toolcontract.TaskRunIDFromContext(ctx))
}
func callNeedsApproval(toolDefinition toolcontract.ToolDefinition, toolInput json.RawMessage) bool {
if toolDefinition.RequiresApproval || OffersChoices(toolDefinition, toolInput) {
return true
}
if !inputSchemaAcceptsApprovalRequired(toolDefinition.InputSchema) {
return false
}
var document struct {
ApprovalRequired bool `json:"approvalRequired"`
}
return json.Unmarshal(toolInput, &document) == nil && document.ApprovalRequired
}
func inputSchemaAcceptsApprovalRequired(inputSchema json.RawMessage) bool {
var schema struct {
Properties map[string]json.RawMessage `json:"properties"`
}
if json.Unmarshal(inputSchema, &schema) != nil {
return false
}
_, isDeclared := schema.Properties["approvalRequired"]
return isDeclared
}
func landsInTheConversationBeingAnswered(turnContext TurnContext, toolDefinition toolcontract.ToolDefinition, toolInput json.RawMessage) bool {
if toolcontract.ToolDefinitionSideEffectClass(toolDefinition) != toolcontract.ToolSideEffectExternalSend {
return false
}
return SendLandsInTheConversationBeingAnswered(turnContext.ConversationType, turnContext.ChannelID, toolInput)
}
func SendLandsInTheConversationBeingAnswered(conversationType string, conversationChannelID string, toolInput json.RawMessage) bool {
var target struct {
TargetType string `json:"targetType"`
ChannelID string `json:"channelID"`
PersonHint string `json:"personHint"`
PersonHints []string `json:"personHints"`
}
if len(toolInput) == 0 || json.Unmarshal(toolInput, &target) != nil {
return false
}
switch strings.TrimSpace(target.TargetType) {
case "currentThread", "currentChannel":
return true
case "channel":
channelID := strings.TrimSpace(target.ChannelID)
return channelID != "" && channelID == strings.TrimSpace(conversationChannelID)
case "directMessage":
addressesOnlyTheRequester := strings.TrimSpace(target.PersonHint) == "" && len(target.PersonHints) == 0
return addressesOnlyTheRequester && strings.EqualFold(strings.TrimSpace(conversationType), "direct")
}
return false
}
func HeldCallResult(notice string) toolcontract.ToolResult {
heldNotice := strings.TrimSpace(notice)
if heldNotice == "" {
heldNotice = "This call is waiting for the requester's approval and has been recorded. Do not retry it now; call it again unchanged once you are told the approval arrived, and it will run."
}
result := toolcontract.ToolFailureResult(toolcontract.FailureUnknown, toolcontract.FailureCodes.InteractionRequired, "approval", heldNotice)
result.Failure.RequiresApproval = true
return result
}
func unanswerableCallResult() toolcontract.ToolResult {
return refusedCallResult("This call needs the requester's approval and there is no conversation they can answer on, so it can never run. Do not wait for an approval; take another route or tell them what you could not do.")
}
func delegatedTurnDeniedResult() toolcontract.ToolResult {
return refusedCallResult("This call needs the requester's approval, and a delegated turn has no one to ask: only the turn that was asked for the work can hold a call for approval. Do not wait for an approval; take another route, or report this back as the part you could not do.")
}
func rejectedCallResult(notice string) toolcontract.ToolResult {
rejectedNotice := strings.TrimSpace(notice)
if rejectedNotice == "" {
rejectedNotice = "The requester declined this call. Do not retry it; choose another way or stop."
}
return refusedCallResult(rejectedNotice)
}
func refusedCallResult(notice string) toolcontract.ToolResult {
return toolcontract.ToolFailureResult(toolcontract.FailureUnknown, toolcontract.FailureCodes.PolicyBlocked, "approval", notice)
}