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) }