package approvalgate import ( "cmp" "context" "encoding/json" "errors" "strings" "time" "github.com/yeomyeonggeori/blueclaw/internal/task" "github.com/yeomyeonggeori/blueprotocol/agentcontract" "github.com/yeomyeonggeori/blueprotocol/holdrecord" "github.com/yeomyeonggeori/blueprotocol/toolcontract" ) const ( TaskEventApprovalDeferred = "approval.deferred" deferredEffect = "created" deferredObjectKey = "schedule" ) var errChoiceStartsAtUnreadable = errors.New("an approval choice's startsAt is not an RFC 3339 instant") type ApprovalAnswer struct { Signal agentcontract.ApprovalSignal ChoiceKey string } func (answer ApprovalAnswer) ChosenFrom(choices []holdrecord.Choice) (holdrecord.Choice, bool) { return ChoiceByKey(choices, answer.ChoiceKey) } type DeferralRequest struct { TaskRunID string ToolName string ToolInput json.RawMessage RequesterPersonID string Platform string ConversationID string ReplyTargetID string Prompt string Choice holdrecord.Choice ReferenceTime time.Time } type deferredCallRecord struct { ToolName string `json:"toolName"` ToolInput json.RawMessage `json:"toolInput,omitempty"` ScheduleID string `json:"scheduleID"` StartsAt string `json:"startsAt"` } func (gate *Gate) DeferApprovedCall(ctx context.Context, request DeferralRequest) (toolcontract.ToolResult, error) { if gate.schedules == nil { return toolcontract.ToolResult{}, errors.New("this agent has no scheduler to hold an approved call for later") } startsAt, errorValue := time.Parse(time.RFC3339, strings.TrimSpace(request.Choice.StartsAt)) if errorValue != nil { return toolcontract.ToolResult{}, errChoiceStartsAtUnreadable } schedule, errorValue := gate.schedules.ScheduleApprovedCall(ctx, task.ApprovedCallScheduleRequest{ Call: task.ScheduleApprovedCall{ ToolName: request.ToolName, ToolInput: request.ToolInput, OriginTaskRunID: request.TaskRunID, }, ApproverPersonID: request.RequesterPersonID, StartsAt: startsAt, Prompt: cmp.Or(strings.TrimSpace(request.Prompt), strings.TrimSpace(request.ToolName)), Delivery: task.ScheduleDeliveryBinding{Platform: request.Platform, ConversationID: request.ConversationID, ReplyTargetID: request.ReplyTargetID}, ReferenceTime: request.ReferenceTime, }) if errorValue != nil { return toolcontract.ToolResult{}, errorValue } record := deferredCallRecord{ ToolName: strings.TrimSpace(request.ToolName), ToolInput: request.ToolInput, ScheduleID: schedule.ScheduleID, StartsAt: startsAt.Format(time.RFC3339), } SettleLatest(gate.taskRunService, request.TaskRunID, holdrecord.DecisionDefer, "approval_choice") gate.taskRunService.AppendTaskEvent(request.TaskRunID, TaskEventApprovalDeferred, marshalEventBody(record)) return deferredCallResult(record), nil } func deferredCallResult(record deferredCallRecord) toolcontract.ToolResult { data, _ := json.Marshal(map[string]any{ "status": "scheduled", "scheduleID": record.ScheduleID, "startsAt": record.StartsAt, "toolName": record.ToolName, "toolInput": record.ToolInput, }) result := toolcontract.ToolSuccessData( "The requester approved "+record.ToolName+" to run at "+record.StartsAt+" instead of now. Schedule "+record.ScheduleID+" holds exactly this call and runs it then without asking again; cancelling that schedule cancels it. It has not run yet.", data, ) result.Effects = []toolcontract.ResourceEffect{{ObjectType: deferredObjectKey, Effect: deferredEffect, ID: record.ScheduleID}} return result } var errNoDeferrer = errors.New("this agent was started without a scheduler for approved calls") type ApprovedCallDeferrer interface { DeferApprovedCall(context.Context, DeferralRequest) (toolcontract.ToolResult, error) } func DeferHeldCall(ctx context.Context, deferrer ApprovedCallDeferrer, heldCall agentcontract.HeldCall, request DeferralRequest) agentcontract.CarriedOutCall { request.ToolName = heldCall.ToolName request.ToolInput = heldCall.ApprovedInput() return agentcontract.CarriedOutCall{ToolName: request.ToolName, ToolInput: request.ToolInput, HoldID: heldCall.HoldID, Result: deferralResult(ctx, deferrer, request)} } func deferralResult(ctx context.Context, deferrer ApprovedCallDeferrer, request DeferralRequest) toolcontract.ToolResult { if deferrer == nil { return DeferralFailedResult(errNoDeferrer) } result, errorValue := deferrer.DeferApprovedCall(ctx, request) if errorValue != nil { return DeferralFailedResult(errorValue) } return result }